Repository navigation
[Fabric] Commits under a display: 'none' view re-run layout over the whole surface (updateYogaChildren sees children Yoga never cleans) #59112
Description
Description
Yoga never clears the dirty flags of the descendants of a display: none node. When it lays out the hidden node's parent, zeroOutLayoutRecursively resets the hidden subtree's layout and the hidden node is marked clean, but its descendants keep whatever dirty flag they had (yoga/algorithm/CalculateLayout.cpp). Fabric's YogaLayoutableShadowNode::layout doesn't recurse into display: none children either. New nodes start dirty, so anything created or changed under a hidden node stays dirty until the node is shown again.
YogaLayoutableShadowNode::updateYogaChildren marks a cloned node dirty if any of its children is dirty. A ShadowTree commit clones every ancestor of the changed node, so any commit that touches something under the hidden node dirties the hidden node, and from there the root. RootShadowNode::layoutIfNeeded then re-runs layoutTree, including roundLayoutResultsToPixelGrid over every node in the surface. None of that can change anything: a display: none node is zero-sized whatever its children are.
React commits don't show the difference, because they already lay out from the root (appendChild always dirties). The commits that pay for it are the ones that don't go through React. In core, the realistic one is a JS-driven Animated loop (useNativeDriver: false) on a view inside a display: 'none' container, e.g. a spinner or a loading shimmer in a collapsed section. On Fabric, JS-driven Animated applies each frame with setNativeProps, so the hidden animation costs a full layout pass every frame.
Measured in the repro (iOS 26.5 simulator, Release, RN 0.87.1 built from source, 1000 rows on screen). Per-commit time on the JS thread, median / p90 over 9 runs of 120 commits:
| stock | with fix | |
|---|---|---|
setNativeProps on a view created inside a display: 'none' parent |
1.06 / 2.02 ms | 0.15 / 0.21 ms |
| … on a view in a hidden parent, after React re-rendered a sibling while hidden | 1.01 / 2.06 ms | 0.16 / 0.20 ms |
JS-driven Animated.loop on a view created inside a display: 'none' parent |
1.09 / 1.95 ms | 0.13 / 0.17 ms |
control: setNativeProps on a view in a visible parent |
0.19 / 0.23 ms | 0.19 / 0.24 ms |
control: … in an opacity: 0 parent |
0.17 / 0.23 ms | 0.17 / 0.22 ms |
| control: … in a parent hidden after its children were laid out, nothing changed since | 0.15 / 0.23 ms | 0.16 / 0.20 ms |
| control: … on a visible sibling of a hidden parent with stale-dirty children | 0.18 / 0.23 ms | 0.18 / 0.22 ms |
Counting with lldb, each of the 120 commits in the affected scenarios ran YogaLayoutableShadowNode::layoutTree on stock, and none did in the controls or with the fix. A breakpoint on yoga::Node::setDirty(true) shows the hidden node turning from clean to dirty in each commit:
node: wasDirty=0 display=none children=2
yoga::Node::setDirty
YogaLayoutableShadowNode::updateYogaChildren
YogaLayoutableShadowNode::YogaLayoutableShadowNode
...
ConcreteComponentDescriptor<ViewShadowNode>::cloneShadowNode
ShadowNode::clone
ShadowNode::cloneTree
...
ShadowTree::tryCommit
...
UIManager::setNativeProps_DEPRECATED
NativeDOM::setNativeProps
After each layout pass, Yoga's display: none branch marks the hidden node clean again, and the next commit dirties it again.
<Activity>: <Activity mode="hidden"> hides its content the same way. The renderer's cloneHiddenInstance clones each top-level host node with {style: {display: 'none'}} and keeps its children. Pre-rendering, and updates rendered while hidden, both leave stale-dirty nodes under it, and a setNativeProps commit into hidden content then re-runs layout from the root (1.18 / 2.03 ms vs 0.16 / 0.21 ms with the fix). But Activity detaches refs and unmounts layout effects when it hides. In the repro, nothing else in core committed into a hidden Activity:
- JS-driven Animated stopped calling
setNativePropsonce hidden (61 calls in 1 s visible, 0 in 2 s hidden); - native-driven Animated doesn't commit to the ShadowTree at all;
scrollTothrough a captured ScrollView instance didn't scroll;- an idle ScrollView, remote Image and TextInput didn't commit.
So for Activity this takes a stale instance captured before hiding, or a third-party library that commits to the ShadowTree without refs. Suspense fallbacks hide content through the same cloneHiddenInstance; we didn't test them.
Broader context
We found this in the Bluesky app. A pull-to-refresh spinner in the profile header is hidden with display: 'none' from a Reanimated useAnimatedStyle driven by the scroll offset. The style kept updating while the spinner was hidden, Reanimated committed it to the ShadowTree on every scroll frame, and each of those commits re-ran layout for the whole screen. We sidestepped it in app code by returning the same style while the spinner is hidden, so Reanimated skips the update. Any display: 'none' subtree with an animation, or anything else that commits outside React, has the same cost.
This is a sibling of #59100 (ModalHostView's adopt() dirtying on every clone, fix in #59101): the same symptom, a different cause.
Steps to reproduce
- Clone https://github.com/mozzius/hidden-subtree-relayout-repro, then run
cd ReproducerApp && yarn && (cd ios && bundle install && bundle exec pod install) && yarn ios --mode Release. - Tap Run all. Each scenario changes a view's color (or opacity, via JS-driven Animated) once per frame, 120 times, and the app shows the median and p90 time per commit. A full run takes about two minutes.
Expected: none of the commits change anything layout-related, so the scenarios with a hidden parent cost the same as the controls.
Actual: commits under a hidden parent whose children were created or changed while hidden re-lay out the whole surface.
The README has the full numbers, the controls, the Activity probes and the call chain: https://github.com/mozzius/hidden-subtree-relayout-repro#readme
Possible fix
In YogaLayoutableShadowNode::updateYogaChildren, don't compare the children of a display: none node, since they can't change its layout. Keep the node's own dirty check and the child count check. Showing the node again changes its style, which dirties it in updateYogaProps. The repro's fixed branch applies exactly that (RN built from source), and the gap closes; see the "with fix" column above. Showing the hidden views again, with setNativeProps or with React, lays them out the same as a never-hidden view. Fix with unit tests: #59113
The alternative would be for Yoga to clear the descendants' dirty flags in zeroOutLayoutRecursively, like it does for display: contents. That changes Yoga for every consumer, and relies on the zeroed layout cache to force a fresh layout when the node is shown, so the Fabric-side check seemed narrower.
React Native Version
0.87.1
Affected Platforms
Runtime - iOS
(Only measured on iOS. The code involved is shared C++, so Android should behave the same.)
Output of npx react-native info
System:
OS: macOS 27.0.1
CPU: (14) arm64 Apple M4 Pro
Memory: 4.54 GB / 48.00 GB
Shell:
version: 5.3.20
path: /opt/homebrew/bin/bash
Binaries:
Node:
version: 24.19.0
path: /Users/samuel/.nvm/versions/node/v24.19.0/bin/node
Yarn:
version: 1.22.22
path: /Users/samuel/.nvm/versions/node/v24.19.0/bin/yarn
npm:
version: 11.17.0
path: /Users/samuel/.nvm/versions/node/v24.19.0/bin/npm
Watchman:
version: 2026.10.05.00
path: /opt/homebrew/bin/watchman
Managers:
CocoaPods:
version: 1.17.0
path: /Users/samuel/.rbenv/shims/pod
SDKs:
iOS SDK:
Platforms:
- DriverKit 27.0
- iOS 27.0
- macOS 27.0
- tvOS 27.0
- visionOS 27.0
- watchOS 27.0
Android SDK:
API Levels:
- "29"
- "33"
- "34"
- "35"
- "36"
- "37"
Build Tools:
- 30.0.3
- 34.0.0
- 35.0.0
- 35.0.1
- 36.0.0
- 37.0.0
System Images:
- android-28 | Google ARM64-V8a Play ARM 64 v8a
- android-29 | Google Play ARM 64 v8a
- android-30 | Google APIs ARM 64 v8a
- android-34 | Google Play ARM 64 v8a
- android-35 | Google Play ARM 64 v8a
- android-35 | Google Play Tablet ARM 64 v8a
- android-36 | Google Play ARM 64 v8a
Android NDK: Not Found
IDEs:
Android Studio: 2026.1 AI-261.26222.65.2614.16379836
Xcode:
version: 27.0/27A266a
path: /usr/bin/xcodebuild
Languages:
Java:
version: 17.0.20.1
path: /usr/bin/javac
Ruby:
version: 2.7.6
path: /Users/samuel/.rbenv/shims/ruby
npmPackages:
"@react-native-community/cli":
installed: 20.2.0
wanted: 20.2.0
react:
installed: 19.2.3
wanted: 19.2.3
react-native:
installed: 0.87.1
wanted: 0.87.1
react-native-macos: Not Found
npmGlobalPackages:
"*react-native*": Not Found
Android:
hermesEnabled: true
newArchEnabled: true
iOS:
hermesEnabled: true
newArchEnabled: true
Activity
Don't dirty `display: none` nodes because of their children (react#59113