Repository navigation
[Fabric] Scrolling inside a <Modal> re-runs layout over the whole surface on every scroll event (ModalHostView adopt() dirties on every clone) #59100
Description
Description
ModalHostViewComponentDescriptor::adopt() calls YogaLayoutableShadowNode::setSize() and setPositionType() on every clone of the ModalHostView. Both always mark the Yoga node dirty, even when the value is unchanged. setPadding(), by contrast, checks first.
A ShadowTree commit clones the path from the changed node to the root. So any commit that touches something inside a visible <Modal> dirties the root, and RootShadowNode::layoutIfNeeded re-runs layoutTree, including Yoga's roundLayoutResultsToPixelGrid, which walks every node in the surface.
The most common case is scrolling a list inside a Modal, e.g. a bottom sheet or a picker. Every scroll event commits a contentOffset state update, and each of those commits re-lays out the entire screen behind the Modal.
Measured in the repro (iOS 26.5 simulator, Release, 1000 rows behind the Modal, 8 identical swipes, JS thread, mean of 3 runs):
| outside Modal | inside Modal | inside Modal, with fix | |
|---|---|---|---|
| JS thread busy | 32 ms | 176 ms | 43 ms |
RootShadowNode::layoutIfNeeded |
0 ms | 129 ms | 0 ms |
of which roundLayoutResultsToPixelGrid |
0 ms | 124 ms | 0 ms |
| layout passes (counted with lldb) | 0 of 188 state updates | 152 of 160 | — |
A breakpoint on yoga::Node::setDirty shows every scroll commit inside the Modal starting with:
setSize <- ModalHostViewComponentDescriptor::adopt <- ConcreteComponentDescriptor<ModalHostViewShadowNode>::cloneShadowNode <- ShadowNode::clone <- ShadowNode::cloneTree <- UIManager::updateState <- ShadowTree::tryCommit
The same scroll commits outside the Modal turn no Yoga node from clean to dirty.
Other triggers:
- The same happens with
setNativeProps, which the repro also times on screen:- iOS: 0.18 ms per commit outside vs 1.47 ms inside (0.22 ms with the fix).
- Android emulator: 0.77 vs 19.9 ms.
- Animation libraries that commit to the ShadowTree (e.g. Reanimated) hit the same path.
- React commits don't show the difference today, because React's Fabric renderer clones ancestors with an empty child list and then calls
appendChild, which always dirties them. See thepassChildrenWhenCloningPersistedNodesTODOs inUIManagerBinding.cpp.
Related:
InputAccessoryComponentDescriptor::adopt()has the samesetSize+setPositionTypepattern.- react-native-screens had the same bug in its screen descriptors. The fix, with measurements from a production app (including a low-end Android device), is in software-mansion/react-native-screens#4922.
Broader context
We found this while profiling scroll performance in the Bluesky app. react-native-screens had the same pattern: its screen and header-config descriptors called setSize() in adopt(). So every non-React commit under a screen re-ran full layout. That meant Reanimated's per-frame header animations, and scroll-offset state updates on every feed. On Android, tab screens never get a frame size, so they hit a size reset on every clone too.
With that fixed (software-mansion/react-native-screens#4922, shipped in bluesky-social/social-app#12000), per ~12s scroll:
- iOS (Release, simulator):
- Profile page: main-thread layout under Reanimated commits went from 468ms to 0, and JS-thread layout under scroll state updates from 139ms to 0.
- Feed: JS-thread layout went from 345ms to 0 (JS thread −28%).
- Samsung Galaxy A10 (Android 11, low-end):
- Profile page: UI-thread layout went from ~1150ms to 0, UI thread busy −20%, median frame time 32 → 22ms, janky frames 78% → 69%.
In every case, ~90% of the cost was roundLayoutResultsToPixelGrid. Any library whose descriptor calls setSize / setPositionType in adopt() has this bug, e.g. react-native-keyboard-controller's OverKeyboardView. Fixing the two setters in core would fix them all at once.
Steps to reproduce
- Clone https://github.com/mozzius/modal-relayout-repro, then run
cd ReproducerApp && yarn && (cd ios && bundle install && bundle exec pod install) && yarn ios --mode Release. - Attach Instruments' Time Profiler.
- Scroll the main list a few times.
- Tap Open scroll Modal and scroll the list inside it the same way.
- Compare the time under
RootShadowNode::layoutIfNeededon the JS thread.
No profiler? Tap Run outside Modal, then Run inside Modal. Each times 120 setNativeProps commits and shows the median and p90 on screen.
Expected: neither scrolling nor a background color change touches layout, so commits inside the Modal cost the same as commits outside it.
Actual: every commit inside the Modal re-lays out the whole surface.
The README has the full numbers, controls and call chain: https://github.com/mozzius/modal-relayout-repro#readme
Possible fix
Have setSize() and setPositionType() return early when the style already has that value, as setPadding() does. The repro's fixed branch applies exactly that (RN built from source), and the gap closes: see the "with fix" column above. I have this change with unit tests in YogaDirtyFlagTest if it's useful.
React Native Version
0.87.1
Affected Platforms
Runtime - iOS, Runtime - Android
Output of npx react-native info
System:
OS: macOS 27.0.1
CPU: (14) arm64 Apple M4 Pro
Memory: 325.50 MB / 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 (#59113)