Skip to content

Skip unchanged children when slicing in the differ - #59242

Open
hryhoriiK97 wants to merge 1 commit into
react:mainfrom
hryhoriiK97:differ-skip-unchanged-children
Open

hryhoriiK97 wants to merge 1 commit into
react:mainfrom
hryhoriiK97:differ-skip-unchanged-children

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

This touches the differ, so I'm happy to split it if that's easier to review: (1) a refactor that moves the per-child logic of sliceChildShadowNodeViewPairs into helpers, with no behavior change, and (2) the change below, behind the new feature flag.

Summary:

When a node changes, the differ (calculateShadowViewMutations, ReactCommon/react/renderer/mounting/Differentiator.cpp) slices the children of its old and new revision with sliceChildShadowNodeViewPairs and compares the two lists. A commit clones only the path from the changed node to the root, so most of those children are the same ShadowNode in both revisions.

Current behavior. Slicing builds a ShadowViewNodePair, including a ShadowView, for every child in both lists, recursing into flattened children, before the comparison finds that a child is unchanged and skips it. While a list scrolls, the ScrollView writes its content offset into its state on every frame, so every frame produces a commit that changes one node deep in the tree, and the differ slices every sibling along the path to it, twice. In the RNTester chat scenario below, 36 of the 56 child pairs compared in such a diff are the same node in both revisions.

With this change. Behind the new skipUnchangedChildrenInDifferentiator feature flag (default false), the differ slices the children of a matched pair side by side with the new sliceChangedChildShadowNodeViewPairs and leaves out each child that is the same node in both revisions at the same offset. Such a child and its flattened descendants give equal pairs in both lists, which the differ matches without a mutation, so the mutations computed from the shorter lists are the same. A child is kept when its offset differs, because a flattened ancestor moved and its frame has to be updated. The side-by-side slicing gives up, and the differ slices both revisions in full as before, when the revisions differ in a way that could change the order or the kind of the pairs: child count, tags, flattening, position type, order index (zIndex), visibility or ChildrenFormStackingContext, or when view culling is enabled.

The per-child logic of the existing slicer moves into helpers that both slicers share, so they can't drift apart.

Changelog:

[Internal]

Test Plan:

  • clang-format --dry-run --Werror on the changed C++ files passes. yarn featureflags regenerated the accessors.
  • Fantom. Mounting-itest.js now runs with @fantom_flags skipUnchangedChildrenInDifferentiator:*, so every existing differ test also runs with the flag on, and it adds tests for an unchanged child next to a changed one, an unchanged child of a flattened view that moves, a scrolled ScrollView with unchanged siblings, a child added next to unchanged children and a child whose zIndex changes. yarn fantom Mounting-itest: 56 passed (14 tests × 4 flag combinations). The full suite (FANTOM_FORCE_CI_MODE=1 yarn fantom) passes with the flag's default switched to true locally, so that every test runs with it on (3,325 passed, 108 skipped), and with it off (same counts), twice each. In a first run with the flag on, the tester crashed once in setUpDefaultReactNativeEnvironment-Globals-ResizeObserver-itest.js; it did not crash again in three runs of that file alone or in two more full runs.
  • Same mutations on device. With a local checker that ran the differ twice for every commit, with and without the side-by-side slicing, and compared the full mutation lists, about 7,500 commits in RNTester on a physical iPhone produced no difference: mounting and unmounting 1000 views, re-rendering, a reflow that moves siblings, opacity animation, list item updates, removeClippedSubviews, adjustsFontSizeToFit text updates, scrolling a chat FlatList, typing in a TextInput, FlatList and SectionList scrolling.
  • Android was not built or tested. The slicing is shared C++, and the Android-specific branches (useTraitHiddenOnAndroid) are mirrored from the existing slicer.
  • Measurement. RNTester built with xcodebuild in Release and run on a physical iPhone 15 Pro (iOS 26.6.1). The two arms are the same binary with the flag switched by a launch argument, in the order before, after, after, before. Scrolling was driven by Argent on the physical iPhone. Timed: the diff phase of each mount transaction from TransactionTelemetry, on the main thread.
RNTester Playground, a FlatList of 500 multi-line messages, 20 flings down and 20 up per launch (~8,800 transactions) before this PR change
diff time per launch 1,102 ms 518 ms −53.4%
diff time per transaction 126.1 µs 58.8 µs −53.4%
diff time per 1–2-mutation transaction (the per-frame scroll updates) 107.2 µs 44.5 µs −58.4%
RNTester Playground, 1000 labels mounted and unmounted, 10 times per arm before this PR change
diff time per mount or unmount (median) 2.54 ms 2.45 ms within noise
RNTester Playground, 1000 cards mounted, then a spacer above them resized so that every card moves (~1,009 mutations per transaction), 12 times per arm before this PR change
diff time per transaction (median) 1.17 ms 0.71 ms −38.9%

A mount gains nothing, since every node is new, and giving up on the side-by-side slicing costs nothing measurable. When every card moves, no card can be left out, but the text inside each card keeps its node and its offset relative to the card, so it is still left out.

🤖 Generated with Claude Code

When a node changes, the differ slices the children of its old and new
revision and compares them. Most of them are the same ShadowNode in both
revisions, since a commit clones only the path to the root, but each one is
still turned into a ShadowViewNodePair in both lists before the comparison
skips it.

Behind the new skipUnchangedChildrenInDifferentiator flag, the children of a
matched pair are sliced side by side, leaving out each child that is the same
node in both revisions at the same offset. Such a child produces no mutation,
so the mutations are unchanged. The side-by-side slicing falls back to slicing
both revisions in full when the child count, tags, flattening, position type,
order index, visibility or ChildrenFormStackingContext differ, or when view
culling is enabled. The per-child logic of the existing slicer moves into
helpers shared by both.

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

Copy link
Copy Markdown
Contributor Author

@javache This touches the differ, so I'd value your view. Is skipping unchanged children like this a direction you'd accept? If so, would you prefer it split into the slicer refactor and the change behind the flag, or reviewed as one?

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 20, 2026
@facebook-github-tools facebook-github-tools Bot added p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. labels Oct 20, 2026
@meta-codesync

meta-codesync Bot commented Oct 20, 2026

Copy link
Copy Markdown

@javache has imported this pull request. If you are a Meta employee, you can view this in D126471123.

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

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant