Skip to content

fix(iOS, Android): only set shadow node size from state when it changes - #4922

Open
mozzius wants to merge 4 commits into
software-mansion:mainfrom
mozzius:fix/screen-set-size-only-when-changed
Open

mozzius wants to merge 4 commits into
software-mansion:mainfrom
mozzius:fix/screen-set-size-only-when-changed

Conversation

@mozzius

@mozzius mozzius commented Oct 14, 2026 •

Copy link
Copy Markdown

Description

Several component descriptors call YogaLayoutableShadowNode::setSize() from adopt(), with a size taken from native state. adopt() runs on every clone. Unlike setPadding(), setSize() marks the Yoga node dirty even when the size hasn't changed.

Every commit clones the path from the changed node up to the root. When a parent is cloned with new children, React Native also re-clones the children it no longer owns, such as a stack's header config next to the screen content. Screens sit near the root of almost every tree, so every commit under them dirtied the root. RootShadowNode::layoutIfNeeded then ran layoutTree plus Yoga's roundLayoutResultsToPixelGrid, which walks the whole tree. That includes commits that change nothing layout-related:

  • Reanimated's per-frame commits (e.g. a header translateY driven by scroll)
  • ScrollView contentOffset state updates, on every scroll event (JS thread)

There are two paths:

The unconditional dirty was also load-bearing for header origins. Several header shadow nodes overwrite their frame origin with one from native state in layout(), and Fabric only calls layout() on a node that has new layout from Yoga. The header config used to be dirtied on every clone, so those origins stayed current. With only the size guard, a state update that just moves a header node (or, on Android, just changes the toolbar height) leaves the tree clean, and measure() and Pressability's press-rect checks read a stale origin. This PR therefore also dirties those nodes when, and only when, the origin from state changes.

Closes #4777. That issue was reported for Android only, but iOS is affected as well.

Changes

  • rnscreens::SizeFromStateShadowNode (common/cpp/.../rnscreens/utils/LayoutFromState.h): a mixin that goes between a shadow node and its ConcreteViewShadowNode base, e.g. SizeFromStateShadowNode<ConcreteViewShadowNode<...>>. It provides setSizeIfChanged(Size), which only calls setSize when the node's Yoga style doesn't already have that width and height. It needs the protected Yoga node, hence a mixin rather than a free function. It only includes <yoga/style/Style.h>. The descriptors that set a size from state in adopt() now call it instead of setSize():
    • v4 (legacy/): Screen (both branches, including the Android undefined reset), ModalScreen, ScreenStackHeaderConfig
    • v5: StackScreen (Android), SplitScreen, FormSheetHost, TabsBottomAccessory, StackHeaderConfig
  • Header origins from state: each node below has a correctFrameOrigin(Point) method that both its layout() and its descriptor use. rnscreens::dirtyLayoutIfFrameOriginStale() (same header) marks the layout dirty in adopt() when that origin differs from the one in the node's layout metrics. Clones inherit the corrected metrics, so an unchanged clone stays clean. The corrections are plain assignments, so the comparison is exact. Hidden (display: none) nodes are skipped, since layout() isn't called for them.
    • v4 ScreenStackHeaderSubview: contentOffset. The descriptor still never calls setSize (see fix(Fabric,iOS): header subviews do not support dynamic content changes #2905): marking the layout dirty doesn't touch the Yoga style.
    • v4 ScreenStackHeaderConfig: frameOrigin on iOS, Y origin from the toolbar height on Android. The Android translucent early return moved into correctFrameOrigin, so a translucent header never dirties.
    • v5 StackHeaderConfig and StackHeaderItem on iOS: contentOffset.
    • Not affected: the v4 Android Screen header-height correction (+=), whose inputs only change together with the Screen's padding or size, which already dirty it; and the constant x = 0 corrections in TabsBottomAccessory and v5 Android StackHeaderSubview.
  • Behaviour otherwise:
    • A clone with new props still resets the style from props, so the size is re-applied and the node dirtied as before.
    • For the undefined reset, StyleSizeLength::points(NaN) is undefined() and undefined values compare equal, so only the first reset dirties.
    • RNSScreenShadowNodeCommitHook's orientation-change reset still calls setSize directly.
    • The first layout is unchanged: a new node is already dirty.
  • Dropped the YogaLayoutableShadowNode RTTI asserts left in descriptors that no longer cast to it.
  • Test screen: Test4777 (issue tests) has four scenarios:
    • long list in a native stack, and long list in JS tabs (ScreenContainer), each with an "Animate color" toggle driven by Reanimated
    • header item positions in a v4 stack: headerLeft / headerRight items whose measure() is shown on screen and logged, with toggles for the title, the back title, the back button and an extra right item
    • header item positions in a v5 stack (iOS): leading / trailing items, with toggles for the title and extra items

Test plan

Test4777, performance:

  1. Pick a "Long list" scenario (native stack: push the list).
  2. Profile with Instruments (Time Profiler) or Perfetto while you:
    • scroll the list (ScrollView state updates)
    • turn on "Animate color" (Reanimated commits on every frame)
  3. Before this change, RootShadowNode::layout / roundLayoutResultsToPixelGrid runs for each of those commits. After it, it doesn't.

The color used to be animated from React state. That didn't test this: when a child re-renders, React re-appends all children of its parent, and YogaLayoutableShadowNode::appendChild always dirties the parent.

Test4777, header item positions:

  1. Pick a "Header item positions" scenario and push the header items screen.
  2. Use the toggles. About a second after each one, the screen shows each item's measure() result (also logged with a [Test4777] prefix). Compare it with where the item is drawn, e.g. its accessibility frame.
  3. "Add extra right item" (v4) and "Add extra items" (v5) move items without resizing them.

Results

Debug builds of FabricExample: iPhone 17 simulator (iOS 27.1) and a Pixel 9 emulator (Android 16, arm64). Positions are in points / dp, native positions come from the accessibility tree.

Header item positions (measure() pageX, vs. where the item is drawn):

drawn at main previous commit of this PR this PR
iOS, v4: headerRight after "Add extra right item" 252.9 252.7 332.7 252.7
iOS, v5: leading / trailing item after "Add extra items" 160.4 / 260.9 160.3 / 261.0 76.0 / 317.0 160.3 / 261.0
iOS, v5: leading / trailing item, after push 76.0 / 317.0 (y 71.7) 76.0 / 317.0 478.0 / 317.0 (trailing y 9.7) 76.0 / 317.0
Android, v4: headerLeft after "Hide back button" 16.0 16.0 72.0 16.0
Android, v4: headerRight after "Add extra right item" 285.5 285.7 349.7 285.7

With the previous commit, a tap with two ~1.5pt moves on the moved headerRight didn't fire onPress on either platform (a plain tap did). With this PR it does.

One thing that isn't related to this PR: on iOS 27, when the back button title collapses or the back button is hidden, UIKit moves headerLeft without native sending a new subview position, so measure() is stale on main too.

Root layout during commits that change no layout:

main this PR
iOS: native stack, scroll (layoutTree / commits) 127 / 128 0 / 415
iOS: native stack, Reanimated color, 10 s 245 / 246 4 / 321
iOS: JS tabs (ScreenContainer), scroll 0 / 46
Android: native stack, scroll (Screen layout() / clones, per screen) 286 / 286 0 / 282
Android: native stack, Reanimated color, 5 s 176 / 176 0 / 176
Android: JS tabs (ScreenContainer), scroll 279 / 279 0 / 291

iOS counts are lldb breakpoint hits on YogaLayoutableShadowNode::layoutTree and RootShadowNode::layoutIfNeeded. The 4 remaining layouts came from React commits that re-append children (appendChild dirties the parent). On Android, breakpoints over adb were too slow to keep the app responsive, so these counts come from a temporary log line in RNSScreenShadowNode::layout() and RNSScreenComponentDescriptor::adopt().

Measured in the Bluesky app, which carries the size guard as a patch on 4.26.2 (bluesky-social/social-app#12000). Each run is a fixed 20-swipe scroll, with baseline and patched builds alternating:

before after
iOS (iPhone 17 simulator, Release, 3 runs each)
Profile screen: main-thread layout under Reanimated commits 468 ms 0
Profile screen: JS-thread layout under ScrollView state updates 139 ms 0
Feed: JS-thread layout under ScrollView state updates 345 ms 0 (JS thread busy −28%)
Android (Samsung Galaxy A10, Android 11, debugOptimized)
Profile screen: UI-thread layout under Reanimated commits ~1150 ms 0
Profile screen: JS-thread layout under state updates ~460 ms 0
Profile screen, unprofiled: UI thread busy / p50 frame time / janky frames 7850 ms / 32 ms / 78% 6262 ms / 22 ms / 69%
Feed: JS-thread layout under state updates ~495 ms 0

On the feed, the Android frame times didn't measurably change, because that screen is bound by drawing on the UI thread.

Before this change, roughly 90% of the layout cost was pixel-grid rounding; the actual layout work was small. In patched builds, the only layout left while scrolling comes from React commits that mount new rows.

Builds and checks: FabricExample builds for the iOS simulator and Android (arm64). On Android, common/cpp compiles with -Wall -Wpedantic -Werror (the app's react_codegen_rnscreens target). clang-format, Prettier, ESLint and yarn check-types pass. I haven't run the Detox e2e or SPM jobs, the tvOS build, or the regression test screens listed in the review, and I haven't tried a physical device.

Not covered by a scenario: an origin-only change of the v4 iOS header config (e.g. hiding the status bar with a translucent header on a device without a notch), and an Android toolbar height change at the same width. Both go through the same check as the cases above.

React Native's own ModalHostViewComponentDescriptor and InputAccessoryComponentDescriptor have the same pattern (setSize + setPositionType in adopt()). That needs a fix in React Native itself: react/react-native#59101. If it lands, the header origin part of this PR is still needed.

Checklist

  • Included code example that can be used to test this change.
  • For visual changes, included screenshots / GIFs / recordings documenting the change. (No visual changes.)
  • For API changes, updated relevant public types. (No API changes.)
  • Ensured that CI passes

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 306898b1-22be-4897-8e32-00cb86560a8f
📥 Commits

Reviewing files that changed from the base of the PR and between 77e4620 and e90b9ce.

📒 Files selected for processing: 25
  • apps/src/tests/issue-tests/Test4777.tsx
  • common/cpp/react/renderer/components/rnscreens/RNSFormSheetHostComponentDescriptor.h
  • common/cpp/react/renderer/components/rnscreens/RNSFormSheetHostShadowNode.h
  • common/cpp/react/renderer/components/rnscreens/RNSSplitScreenComponentDescriptor.h
  • common/cpp/react/renderer/components/rnscreens/RNSSplitScreenShadowNode.h
  • common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigComponentDescriptor.h
  • common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.cpp
  • common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.h
  • common/cpp/react/renderer/components/rnscreens/RNSStackHeaderItemComponentDescriptor.h
  • common/cpp/react/renderer/components/rnscreens/RNSStackHeaderItemShadowNode.cpp
  • common/cpp/react/renderer/components/rnscreens/RNSStackHeaderItemShadowNode.h
  • common/cpp/react/renderer/components/rnscreens/RNSStackScreenComponentDescriptor.h
  • common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.h
  • common/cpp/react/renderer/components/rnscreens/RNSTabsBottomAccessoryShadowNode.h
  • common/cpp/react/renderer/components/rnscreens/legacy/RNSModalScreenComponentDescriptor.h
  • common/cpp/react/renderer/components/rnscreens/legacy/RNSModalScreenShadowNode.h
  • common/cpp/react/renderer/components/rnscreens/legacy/RNSScreenComponentDescriptor.h
  • common/cpp/react/renderer/components/rnscreens/legacy/RNSScreenShadowNode.h
  • common/cpp/react/renderer/components/rnscreens/legacy/RNSScreenStackHeaderConfigComponentDescriptor.h
  • common/cpp/react/renderer/components/rnscreens/legacy/RNSScreenStackHeaderConfigShadowNode.cpp
  • common/cpp/react/renderer/components/rnscreens/legacy/RNSScreenStackHeaderConfigShadowNode.h
  • common/cpp/react/renderer/components/rnscreens/legacy/RNSScreenStackHeaderSubviewComponentDescriptor.h
  • common/cpp/react/renderer/components/rnscreens/legacy/RNSScreenStackHeaderSubviewShadowNode.cpp
  • common/cpp/react/renderer/components/rnscreens/legacy/RNSScreenStackHeaderSubviewShadowNode.h
  • common/cpp/react/renderer/components/rnscreens/utils/LayoutFromState.h
💤 Files with no reviewable changes: 5
  • common/cpp/react/renderer/components/rnscreens/RNSStackScreenComponentDescriptor.h
  • common/cpp/react/renderer/components/rnscreens/RNSSplitScreenComponentDescriptor.h
  • common/cpp/react/renderer/components/rnscreens/legacy/RNSScreenComponentDescriptor.h
  • common/cpp/react/renderer/components/rnscreens/RNSFormSheetHostComponentDescriptor.h
  • common/cpp/react/renderer/components/rnscreens/legacy/RNSModalScreenComponentDescriptor.h

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

📝 Walkthrough

Screen shadow nodes now avoid setting unchanged frame sizes. Header descriptors check corrected frame origins and dirty stale layout. The issue-test app adds long-list and header-layout scenarios for issue 4777.

Changes: Screen size updates and issue reproduction

Layer / File(s) Summary
Shared layout-state helpers
common/cpp/react/renderer/components/rnscreens/utils/LayoutFromState.h
Adds a wrapper that calls setSize only when Yoga dimensions differ. Adds a helper that dirties visible nodes when their corrected frame origin is stale.
Screen frame-size updates
common/cpp/react/renderer/components/rnscreens/RNSFormSheet*, RNSSplitScreen*, RNSStackScreen*, RNSTabsBottomAccessory*, common/cpp/react/renderer/components/rnscreens/legacy/RNSModalScreen*, RNSScreen*
These shadow nodes use the size wrapper. Their descriptors apply frame sizes through setSizeIfChanged, including the legacy screen zero-size reset.
Header frame-origin corrections
common/cpp/react/renderer/components/rnscreens/RNSStackHeader*, common/cpp/react/renderer/components/rnscreens/legacy/RNSScreenStackHeader*
Header shadow nodes provide corrected frame origins. Header descriptors check for stale layout during adoption, and header config layout applies the corrected origin.
Issue 4777 reproduction screen
apps/src/tests/issue-tests/Test4777.tsx, apps/src/tests/issue-tests/index.ts
Adds 500-row list scenarios, v4 and iOS v5 header scenarios, measurement and press-count reporting, and scenario selection. Exports the test screen from the issue-test index.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Bug fix · Severity of issue fixed: Medium

Suggested reviewers: kmichalikk

Merge Risk: ⚪ Minimal · up to e90b9

The changed layout behavior and issue scenarios have no established blocker; the PR is mergeable after normal checks.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 10.17% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 59 functions across 28 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Linked Issues check Passed Issue #4777 requires Android Fabric screen adoption to avoid dirtying Yoga when the native frame size is unchanged, including screens with a persistent zero frame. `RNSScreenComponentDescriptor::adopt…
Out of Scope Changes check Passed The v4 and v5 size guards address the same state-driven setSize pattern described by the PR. Header-origin invalidation supports the stated regression requirement for measure() and press-rectangle…
Title check Passed The title clearly and concisely describes the primary change: guarding shadow node size updates on iOS and Android so they occur only when the size changes.
Description check Passed The description is directly related to the changeset. It explains the size-update optimization, header-origin invalidation, affected components, tests, profiling results, and known coverage limits.
Full details: Linked Issues check

Explanation

Issue #4777 requires Android Fabric screen adoption to avoid dirtying Yoga when the native frame size is unchanged, including screens with a persistent zero frame. RNSScreenComponentDescriptor::adopt now calls setSizeIfChanged for both the reported-frame path and the zero-frame path. SizeFromStateShadowNode::setSizeIfChanged compares both Yoga dimensions before calling setSize. The Test4777 screen covers native-stack scrolling, JS-tab scrolling, and per-frame Reanimated commits. These changes satisfy the linked issue's coding requirement.

Full details: Out of Scope Changes check

Explanation

The v4 and v5 size guards address the same state-driven setSize pattern described by the PR. Header-origin invalidation supports the stated regression requirement for measure() and press-rectangle coordinates after the size guard. Test4777 provides focused verification scenarios. No unrelated change is established by the reviewed changes.

  • Fix all pre-merge checks with AI
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mozzius

mozzius commented Oct 15, 2026

Copy link
Copy Markdown
Author

Fyi: I have filed a PR to fix this properly upstream here - react/react-native#59101

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Size-only guards can skip layout needed to refresh state-driven header coordinates.

3 open findings
What changed in this PR

Reduces unnecessary Fabric layout work on iOS and Android by avoiding redundant state-driven size updates, addressing #4777.

Changes:

  • Adds a shared Yoga size comparison helper.
  • Guards size updates across legacy and v5 navigation components.
  • Adds a profiling reproduction screen.
File Description
common/​cpp/​react/​renderer/​components/​rnscreens/​utils/​YogaSizeUtil.h Adds size comparison helper.
common/​cpp/​react/​renderer/​components/​rnscreens/​RNSTabsBottomAccessoryShadowNode.h Adds guarded size setter.
common/​cpp/​react/​renderer/​components/​rnscreens/​RNSTabsBottomAccessoryComponentDescriptor.h Uses guarded accessory sizing.
common/​cpp/​react/​renderer/​components/​rnscreens/​RNSStackScreenShadowNode.h Adds guarded size setter.
common/​cpp/​react/​renderer/​components/​rnscreens/​RNSStackScreenComponentDescriptor.h Guards Android screen sizing.
common/​cpp/​react/​renderer/​components/​rnscreens/​RNSStackHeaderConfigShadowNode.h Adds guarded size setter.
common/​cpp/​react/​renderer/​components/​rnscreens/​RNSStackHeaderConfigComponentDescriptor.h Guards header sizing.
common/​cpp/​react/​renderer/​components/​rnscreens/​RNSSplitScreenShadowNode.h Adds guarded size setter.
common/​cpp/​react/​renderer/​components/​rnscreens/​RNSSplitScreenComponentDescriptor.h Guards split-screen sizing.
common/​cpp/​react/​renderer/​components/​rnscreens/​RNSFormSheetHostShadowNode.h Adds guarded size setter.
common/​cpp/​react/​renderer/​components/​rnscreens/​RNSFormSheetHostComponentDescriptor.h Guards form-sheet sizing.
common/​cpp/​react/​renderer/​components/​rnscreens/​legacy/​RNSScreenStackHeaderConfigShadowNode.h Adds guarded size setter.
common/​cpp/​react/​renderer/​components/​rnscreens/​legacy/​RNSScreenStackHeaderConfigComponentDescriptor.h Guards legacy header sizing.
common/​cpp/​react/​renderer/​components/​rnscreens/​legacy/​RNSScreenShadowNode.h Adds guarded size setter.
common/​cpp/​react/​renderer/​components/​rnscreens/​legacy/​RNSScreenComponentDescriptor.h Guards sizing and Android undefined resets.
common/​cpp/​react/​renderer/​components/​rnscreens/​legacy/​RNSModalScreenShadowNode.h Adds guarded size setter.
common/​cpp/​react/​renderer/​components/​rnscreens/​legacy/​RNSModalScreenComponentDescriptor.h Guards modal sizing.
apps/​src/​tests/​issue-tests/​Test4777.tsx Adds scroll and animation profiling scenario.
apps/​src/​tests/​issue-tests/​index.ts Registers the reproduction screen.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +33 to 34
configShadowNode.setSizeIfChanged(
Size{stateData.frameSize.width, stateData.frameSize.height});
Comment on lines +39 to +40
configShadowNode.setSizeIfChanged(
{stateData.frameSize.width, YGUndefined});
#else
if (stateData.frameSize.width != 0 && stateData.frameSize.height != 0) {
layoutableShadowNode.setSize(stateData.frameSize);
configShadowNode.setSizeIfChanged(stateData.frameSize);

@kkafar kkafar left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This is an automated review generated by Claude Code, not a review written by @kkafar. Kacper asked for it to be posted as-is. Findings were not verified on a device. Treat them as leads to check, not as confirmed bugs.

Summary

The perf diagnosis is right and the guard is cleaner than the one suggested in #4777: it compares against exactly what setSize writes into the Yoga style. But the unconditional dirty in adopt() was load-bearing for the header shadow nodes, so the claim "Behaviour is otherwise unchanged" doesn't hold.

Fabric only calls a child's layout() override when Yoga reports new layout for it (YogaLayoutableShadowNode::layout → getHasNewLayout()). A state-only clone doesn't dirty a node. Several header nodes patch layoutMetrics_.frame.origin from state inside layout():

Node layout() correction reads
v4 RNSScreenStackHeaderSubviewShadowNode (iOS + Android) stateData.contentOffset
v4 RNSScreenStackHeaderConfigShadowNode iOS stateData.frameOrigin
v4 RNSScreenStackHeaderConfigShadowNode Android -stateData.frameSize.height (height isn't in the style, which is {width, YGUndefined})
v5 RNSStackHeaderConfigShadowNode / RNSStackHeaderItemShadowNode iOS stateData.contentOffset

Before this PR, any state update on these nodes re-cloned the header config, and setSize dirtied it, so layout re-ran and the corrections were re-applied. Even unrelated commits under the Screen did this, which hid ordering issues. After this PR, an origin-only (or Android height-only) state update produces a clean tree, RootShadowNode::layoutIfNeeded returns early, and the shadow-tree origin stays stale until the config's props change.

That is exactly what measure(), Reanimated measure and Pressability's press-rect check read. It risks bringing back the "header button onPress doesn't fire on a physical device" class of bugs (#2219 / #2466, #2855 / #2858, #2714 / #2905 / #2910).

Possible fixes:

  • (a) compute the header origins on read via getContentOriginOffset, as v5 Android already does;
  • (b) in those descriptors, dirtyLayout() only when the state-derived origin differs from getLayoutMetrics().frame.origin (mirroring the Android translucent early return).

Separately, the root cause belongs upstream: YogaLayoutableShadowNode::setSize should early-out the way setPadding already does.

Details are in the inline comments, ranked most-severe first. A list of test screens to check for regressions will follow in a separate comment.


Generated by Claude Code

#else
if (stateData.frameSize.width != 0 && stateData.frameSize.height != 0) {
layoutableShadowNode.setSize(stateData.frameSize);
configShadowNode.setSizeIfChanged(stateData.frameSize);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness, high] v4 header subview origin corrections stop being re-applied (iOS + Android).

RNSScreenStackHeaderSubviewShadowNode::layout() writes stateData.contentOffset into the frame origin, but it runs only when the subview gets new layout. That used to happen on every subview state update, because the update re-clones this config and the unconditional setSize dirtied it. Now the config stays clean and the subview's origin goes stale.

Scenario: v4 native stack with a headerRight/headerLeft Pressable. Change headerBackTitle or the title, or make the back button appear. The props commit lays the header out with the old subview offset. UIKit (or the Toolbar) then moves the item and sends a subview state update with a new origin and the same size, and that commit dirties nothing. measure() returns stale coordinates. On a physical device, small finger movement makes Pressability think the touch left the press rect, so onPress never fires.


Generated by Claude Code


if (stateData.frameSize.width != 0 && stateData.frameSize.height != 0) {
layoutableShadowNode.setSize(
configShadowNode.setSizeIfChanged(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness, high] v5 iOS header config and header item origins go stale.

RNSStackHeaderConfigShadowNode::layout() and RNSStackHeaderItemShadowNode::layout() (iOS) both set the origin from stateData.contentOffset. Items are children of the config, so their corrections relied on this setSize dirtying the config on every clone.

Scenario: an item moves within the bar but keeps its size (a neighbour item is added or removed, the title changes, iOS 26 regroups items). RNSStackHeaderItemShadowStateProxy sends a new origin, no node is dirty, layout is skipped, and measure / press-rect checks are off by the move distance. The same applies to the config itself when only the navbar origin changes.


Generated by Claude Code

#ifdef ANDROID
if (stateData.frameSize.width != 0) {
layoutableShadowNode.setSize({stateData.frameSize.width, YGUndefined});
configShadowNode.setSizeIfChanged(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness, medium-high] Android v4: a header height change no longer re-runs layout.

applyFrameCorrections() sets origin.y = -stateData.frameSize.height, but only the width goes into the Yoga style here ({width, YGUndefined}), so yogaStyleHasSize ignores height.

Scenario: a non-translucent header whose toolbar height changes at the same width (top inset / status-bar translucency, font scale). If this config's state commit lands after the Screen's frame commit (they're separate state updates, and the order isn't guaranteed), nothing is dirty. origin.y stays at -oldHeight, and every header subview's measured pageY is off by the height delta.


Generated by Claude Code

#else
if (stateData.frameSize.width != 0 && stateData.frameSize.height != 0) {
layoutableShadowNode.setSize(stateData.frameSize);
configShadowNode.setSizeIfChanged(stateData.frameSize);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness, medium-high] iOS v4: origin-only frameOrigin updates are dropped.

frameOrigin isn't part of the Yoga style, and setPadding already early-outs. So a state update that only moves the navbar (same size, same insets) leaves the config clean, and the new origin never reaches layoutMetrics_.

Scenario: a translucent header on a device where hiding the status bar changes the top safe area. Toggle statusBarHidden: the navbar moves up and keeps its 44pt height. The header config and all its subviews are then measured at the old y.


Generated by Claude Code

* rounding over the whole tree on every commit, including ones that change
* nothing layout-related (scroll offset state updates, animated transforms).
*/
inline bool yogaStyleHasSize(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[altitude] The guard needs a structural partner.

The doc comment presents the guard as safe, but the unconditional dirty was the only thing keeping the state-derived corrections in layout() overrides current. Either move those corrections out of layout() (compute them on read in getContentOriginOffset, as v5 Android does), or dirty explicitly in adopt() when the state origin differs from getLayoutMetrics().frame.origin.

Also consider an RN PR making YogaLayoutableShadowNode::setSize early-out like setPadding. That would fix RN's own Modal/InputAccessory descriptors too.


Generated by Claude Code

const pop = () => setShowList(false);

return (
<ScreenStack style={{ flex: 1 }}>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test-coverage] The test misses the issue's reported path and the geometry this change can regress.

Only ScreenStack screens are rendered (the frame-known branch). The original #4777 scenario, a Screen in a ScreenContainer / JS bottom tabs (the frameSize == 0 reset branch on Android), isn't exercised. Nothing checks that header subview/item positions stay correct either.

Suggestions: add a JS-tabs variant, plus a headerRight item whose measure() is logged after toggling the back title or title.


Generated by Claude Code


// Like `setSize`, but leaves the Yoga node clean when the size is unchanged.
// See `rnscreens::yogaStyleHasSize`.
void setSizeIfChanged(Size size) const {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[simplification] setSizeIfChanged is copy-pasted into 8 shadow nodes.

yogaNode_ is protected, so a free function can't reach it. A small shared base or mixin over ConcreteViewShadowNode would keep this in one place. As it stands, the next change to the guard (e.g. also dirtying on origin changes, per the comments above) has to be made 8 times, and a missed copy reintroduces either the perf bug or a stale-layout bug.


Generated by Claude Code

@@ -19,16 +19,14 @@ class RNSFormSheetHostComponentDescriptor final

react_native_assert(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[simplification, nit] The layoutableShadowNode casts were removed, but the react_native_assert(dynamic_cast<YogaLayoutableShadowNode *>(...)) that guarded them stays. The same is true in SplitScreen, StackScreen, StackHeaderConfig, ModalScreen and Screen. The static type already guarantees this, so these are now dead RTTI checks in debug adopt().


Generated by Claude Code

Comment thread apps/src/tests/issue-tests/Test4777.tsx Outdated
// the whole tree, even when nothing layout-related changed.
//
// Both sources of commits below change no layout: scrolling (ScrollView
// content offset state updates) and a color that changes every frame. Profile

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[docs, nit] "a color that changes every frame": Math.floor(frame / 10) changes it every 10th frame. The other 9 renders produce no host prop diff, so no Screen clone and no commit to measure. The PR test plan says the same thing. Drop the / 10 or fix the wording.


Generated by Claude Code

@@ -0,0 +1,31 @@
#pragma once

#include <react/renderer/components/view/YogaLayoutableShadowNode.h>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Only yoga::Style / StyleSizeLength and react::Size are needed. Including the whole YogaLayoutableShadowNode.h adds compile-time coupling to the 8 shadow-node headers that now include this file. <yoga/style/Style.h> should be enough.


Generated by Claude Code

@kkafar

kkafar commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Note

Generated automatically by Claude Code, not written by @kkafar. Kacper asked for it to be posted as-is. It was compiled from git log --follow over the shadow nodes / descriptors this PR touches (plus the header subview and item nodes whose layout() corrections it affects) and the PRs' linked issues. Formatting-only, file-move, and RN-version-bump commits are omitted. The PR → test mapping may be incomplete.

PRs and issues that touched this code

v4 Screen (frame size from state, Android frame corrections, orientation commit hook)

PR Linked issue Test screen(s)
#2028 correct measure with native header — Test2028
#2169 (Android) jumping content with native header — TestHeader
#2214 (Android) missing negation in mismatched-frame check — —
#2292 (Android) pressable on Screen loses focus on pointer movement #1975 Test1975, TestHeader
#3295 (Android) layout shift on orientation change #2933 Test2933
#3475 (Android) crash on hot reload in commit hook #3476 —
#3508 (Android) generalise commit-hook screen-size reset labs#598 Test2933
#4116 (Android) translucent header first layout correction #3530 —
#4357 (Android) measured header height with top inset disabled — Test4220

v4 header config / header subviews (state-driven origin corrections, the area most at risk here)

PR Linked issue Test screen(s)
#2325 (Android) overflowing text in native header #1946 — (see note below)
#2466 clicking on Pressable located in screen header #2219 Test2466
#2696 (Android) infinite state update loop for zero-size header subviews #2675 Test2675
#2807 (Android) header subviews disappearing after hot reload — TestHeaderTitle
#2811 (Android) Yoga stretch-fitting header subview height — Test2811, TestHeaderTitle
#2858 (iOS) TouchableOpacity with headerTranslucent on notchless devices #2855 Test2855, Test2466
#2905 (iOS) header subviews don't support dynamic content changes #2714, #2815 Test2714, Test2466, TestHeaderTitle
#2910 (Android) same as above #2714, #2815 Test2714, Test2811, TestHeaderTitle
#3239 (Android) header config wrong origin when translucent — —
#4286 (iOS) header positioning in shadow tree labs#1594 Test3111, Test3446

v4 FullWindowOverlay / ModalScreen

PR Linked issue Test screen(s)
#2641 (iOS) invalid position of FullWindowOverlay #2631 Test2631

v5 components (StackScreen, StackHeaderConfig / items, SplitScreen, FormSheetHost, TabsBottomAccessory)

PR Linked issue Test screen(s)
#3753 (Android, Stack v5) basic header support labs#892, labs#903 stack-v5/test-stack-subviews-android
#3796 (Android, Stack v5) header config and custom subviews labs#916 stack-v5/test-stack-subviews-android, stack-v5/test-stack-header-content-insets-android
#3868 (iOS, Stack v5) subviews layout in header labs#856 stack-v5/test-stack-subviews-ios, stack-v5/test-stack-header-subview-onpress-ios
#4277 (iOS, Stack v5) icon loading in header items — stack-v5/test-stack-header-icon-ios
#3288 (iOS, Tabs) bottomAccessory — Test3288, tabs/test-tabs-bottom-accessory-{layout,size,visibility}-ios
#3613 (Tabs) native RTL (bottom accessory origin) — Test3288, TestBottomTabs
#3947 (iOS, FormSheet v5) standalone FormSheet labs#1254–labs#1258, labs#1274 form-sheet/test-form-sheet-*
#4189 (Android, FormSheet v5) standalone FormSheet labs#1547 form-sheet/test-form-sheet-*
#4306 (iOS, FormSheet v5) RootNodeKind trait labs#1421 form-sheet/test-form-sheet-base, form-sheet/test-form-sheet-fit-to-contents
SplitScreen: shadow nodes came from the labs repo (no PR in this repo) — split/test-split-pressables-ios

⚠️ PRs with no test screen found (needs manual testing)

These PRs touched the affected code, but I couldn't find a test screen for them in apps/, neither by name in the PR body nor by issue number. They need a manual check, with a scenario built from the PR description:

  • #2214: (Android) missing negation in the mismatched-frame check in RNSScreenComponentDescriptor (debug log only)
  • #3239: (Android) header config shadow node has the wrong origin when translucent == true (directly relevant: layout() correction in RNSScreenStackHeaderConfigShadowNode)
  • #3475 / #3476: (Android) crash on hot reload in the orientation commit hook
  • #4116 / #3530: (Android) translucent header first-layout correction (directly relevant: Screen frame corrections on the first layout)
  • #2325 / #1946: (Android) overflowing text in the native header. Correction: an earlier version of this comment mapped it to Test1649, but that is an unrelated form-sheet test. It isn't exported from the issue-tests index, and it fails to bundle (Unable to resolve module jotai). Header-title ellipsizing is partially covered by TestHeaderTitle and stack-v5/test-stack-subviews-android.
  • SplitScreen shadow nodes: they came from the labs repo, so there's no PR here. Only split/test-split-pressables-ios covers them.

The PR → test-screen mapping above was built from PR bodies and file names, so it may be incomplete.

Suggested regression checklist

Priority 1 (header origin corrections; test on a physical device, where Pressability press-rect checks matter):

  • Test2466
  • Test2855
  • Test2714
  • TestHeaderTitle
  • Test2811
  • Test2675
  • Test3111
  • Test3446
  • stack-v5/test-stack-header-subview-onpress-ios
  • stack-v5/test-stack-subviews-ios
  • stack-v5/test-stack-subviews-android
  • stack-v5/test-stack-header-item-visibility-priority-ios
  • stack-v5/test-stack-header-bar-minimization-ios

Priority 2 (Screen frame / header height corrections, orientation):

Priority 3 (other touched v5 nodes):

  • Test2631
  • Test3288
  • TestBottomTabs
  • tabs/test-tabs-bottom-accessory-*
  • form-sheet/test-form-sheet-base
  • form-sheet/test-form-sheet-fit-to-contents
  • form-sheet/test-form-sheet-orientation-change
  • split/test-split-pressables-ios

Generated by Claude Code

@kkafar kkafar left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR. I haven't managed to review today, will do so on Monday.

In general I like the changes, but these touch very fragile part of the code, especially in v4 version. Therefore I've asked LLM to go through git blame and list issues / PRs that touched this code and added regression test cases - we'll have to go through all of them to make sure that this change does not introduce regressions.

In principle I believe this should be fixed upstream, but we can land a temporary patch, before these changes land in core.

@mozzius

mozzius commented Oct 16, 2026

Copy link
Copy Markdown
Author

Thanks @kkafar! I'll point my claude at the feedback, see what it thinks

@mozzius

mozzius commented Oct 16, 2026

Copy link
Copy Markdown
Author

Note

This comment was written by an LLM (Claude Code, working with @mozzius). Samuel asked for it to be posted. The code reading below was checked against the 4.30.0 source, but nothing in it has been verified on a device yet.

Thanks for the thorough review! The main finding holds up. Several header shadow nodes overwrite their frame origin from state inside layout(), and Fabric only calls layout() when Yoga has new layout for the node. The unconditional dirty from setSize in adopt() is what kept those corrections current:

  • v4 RNSScreenStackHeaderSubviewShadowNode: origin = stateData.contentOffset. Its descriptor deliberately never calls setSize (the comment there points to fix(Fabric,iOS): header subviews do not support dynamic content changes #2905), so it relied entirely on the parent config being dirtied when it re-cloned.
  • v4 RNSScreenStackHeaderConfigShadowNode:
    • iOS: origin = stateData.frameOrigin.
    • Android, non-translucent: origin.y = -stateData.frameSize.height. The height isn't in the Yoga style, so the size guard can't see it change.
  • v5 iOS RNSStackHeaderConfigShadowNode / RNSStackHeaderItemShadowNode: origin = stateData.contentOffset.

Plan. I'll go with (b):

  • In each of those descriptors' adopt(), call dirtyLayout() only when the origin that layout() would apply differs from getLayoutMetrics().frame.origin. The corrections are plain assignments, so the comparison is exact.
  • Clones that change nothing stay clean, so the perf fix is kept, and origin-only or height-only state updates re-run layout again.
  • (a), computing the offsets on read through getContentOriginOffset like v5 Android, looks cleaner structurally. It's a bigger change to the v4 code, though, and for subviews it would offset the children rather than the node itself, so I'd leave that call to you.

I'll also move setSizeIfChanged into one shared mixin instead of 8 copies, as suggested. And I'll extend Test4777 with a headerRight whose measure() is logged after changing the title or back title. That should show the stale origin with the current version of this PR and the fix with the next one.

Upstream. The core fix is filed as react/react-native#59101 (issue react/react-native#59100). If it lands, header origins go stale in exactly the same way, whether or not this PR merges, because screens' setSize calls stop dirtying. So the (b) part is needed either way. It may make sense to land it separately from the setSize guard.

For context: Bluesky ships this patch, but we don't use native header items and hide native headers almost everywhere, so we haven't hit it in production. It is a real regression for apps that use header items, though.

mozzius and others added 3 commits October 16, 2026 23:05
Add scenarios that measure() header items (v4 stack, and v5 stack on
iOS) after changes that move them without resizing them, and a long
list in a ScreenContainer, which keeps an Android Screen in the
"frame unknown" branch of its descriptor.

Animate the color with Reanimated: from React, only every 10th frame
changed the color, and when a child re-renders React re-appends all
children of its parent, which dirties the parent anyway. Stack screens
are now positioned absolutely, like ScreenStackItem does, so measured
positions are comparable with the native ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mixin

Replace the 8 copies of `setSizeIfChanged` with
`rnscreens::SizeFromStateShadowNode`, a mixin that goes between a shadow
node and its `ConcreteViewShadowNode` base, since it needs the protected
Yoga node. It replaces `utils/YogaSizeUtil.h` and only includes
`<yoga/style/Style.h>` instead of all of `YogaLayoutableShadowNode.h`.

Also drop the `YogaLayoutableShadowNode` RTTI asserts left behind in the
descriptors that no longer cast to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Several header shadow nodes overwrite their frame origin with one from
native state in `layout()`, which Fabric only calls when Yoga has new
layout for the node. The unconditional `setSize` in `adopt()` used to
dirty the header config on every clone, which kept those origins
current. With the size guard, a state update that only moves a header
node (or, on Android, only changes the toolbar height) left the tree
clean, so `measure()` and Pressability's press-rect checks read a stale
origin.

Each affected node now exposes `correctFrameOrigin()`, which `layout()`
and the descriptor share, and its descriptor marks the layout dirty in
`adopt()` when that origin differs from the one in the node's layout
metrics. Clones inherit the corrected metrics, so a clone whose origin
didn't change stays clean:

- v4 `RNSScreenStackHeaderSubview` (`contentOffset`), without touching
  its Yoga style (see software-mansion#2905)
- v4 `RNSScreenStackHeaderConfig` (iOS: `frameOrigin`, Android: Y origin
  from the toolbar height, except for translucent headers)
- v5 `RNSStackHeaderConfig` and `RNSStackHeaderItem` on iOS
  (`contentOffset`)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mozzius

mozzius commented Oct 17, 2026

Copy link
Copy Markdown
Author

Note

This comment was written by an LLM (Claude Code, working with @mozzius). The results below come from an iPhone 17 simulator (iOS 27.1) and a Pixel 9 emulator (Android 16), not from a physical device.

There are three new commits on top of the previous one, and the description now has the results. Going through the inline comments:

  • v4 header subview origins (high): fixed. Each affected node now has a correctFrameOrigin() that both layout() and the descriptor use. In adopt(), dirtyLayoutIfFrameOriginStale() marks the node dirty when that origin differs from the one in its layout metrics. Clones inherit the corrected metrics, so unchanged clones stay clean. The subview descriptor still never calls setSize (fix(Fabric,iOS): header subviews do not support dynamic content changes #2905). In Test4777, after "Add extra right item", measure() of headerRight was 332.7 (drawn at 252.9) and is now 252.7. A tap with two small moves didn't fire onPress before, and now it does. On Android, the same check after "Hide back button" went from 72.0 to 16.0.
  • v5 iOS header config and items (high): fixed the same way, in both descriptors (the item descriptor gets an adopt() override). After "Add extra items", the leading and trailing items measured 76.0 / 317.0 (drawn at 160.4 / 260.9), and now 160.3 / 261.0. Right after a push, the leading item was off by the screen width.
  • Android v4 header height (medium-high): covered. correctFrameOrigin() sets origin.y = -frameSize.height, so a height-only state update dirties the config. The translucent early return moved into correctFrameOrigin(), so translucent headers never dirty. I didn't build a scenario that changes only the toolbar height.
  • iOS v4 frameOrigin (medium-high): covered by the same check. I couldn't reproduce an origin-only navbar move on the iPhone 17 simulator, because hiding the status bar doesn't change the safe area there.
  • Structural partner (altitude): I went with (b), dirtying in adopt(). Computing the origins on read would have meant a bigger change to v4. This is needed whether or not Only dirty Yoga nodes in setSize/setPositionType when the value changes react/react-native#59101 lands.
  • Test coverage: Test4777 now has a long list in JS tabs (ScreenContainer), plus header item scenarios for v4 and v5 (iOS) that show and log measure(). On Android, the JS tabs Screen re-ran layout on every clone before (279 of 279 while scrolling), and doesn't now (0 of 291).
  • setSizeIfChanged copy-pasted 8 times: replaced with rnscreens::SizeFromStateShadowNode<Base>, a mixin between each node and its ConcreteViewShadowNode base. It lives in utils/LayoutFromState.h with the origin helper, and YogaSizeUtil.h is gone.
  • Dead RTTI asserts: removed.
  • "Changes every frame": the color is now animated with Reanimated. Animating it from React didn't test this at all: when a child re-renders, React re-appends all children of its parent, and appendChild always dirties the parent.
  • Includes: the new header only includes <yoga/style/Style.h> and two small RN headers, not YogaLayoutableShadowNode.h.

With these changes, scrolling and per-frame Reanimated commits still don't run root layout (iOS: 0 layoutTree hits in 415 commits while scrolling). The Copilot comments are the same three origin cases, so they're covered too. I haven't gone through the regression checklist, apart from Test4777.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Android][Fabric] Screen adopt() calls setSize on every clone, forcing a full layout pass on every commit (e.g. every scroll frame)

3 participants