Skip to content

Apply setNativeProps props to a cloned node only when they changed - #59624

Open
hryhoriiK97 wants to merge 5 commits into
react:mainfrom
hryhoriiK97:fabric-skip-reapplying-native-props
Open

hryhoriiK97 wants to merge 5 commits into
react:mainfrom
hryhoriiK97:fabric-skip-reapplying-native-props

Conversation

@hryhoriiK97

Copy link
Copy Markdown
Contributor

Summary:

Once a component's props are set with setNativeProps, ShadowNode::clone applies family.nativeProps_DEPRECATED again every time one of its shadow nodes is cloned with unchanged props: it parses them into new props with cloneProps. That includes the clones Yoga makes of every child of a node it lays out (YogaLayoutableShadowNode::adoptYogaChild), so each commit that touches a parent re-parses the native props of all its children that ever had setNativeProps, and nativeProps_DEPRECATED stays set for the life of the component.

On Fabric, JS-driven Animated (useNativeDriver: false) updates props with setNativeProps on every frame (shouldUseSetNativePropsInFabric, on by default). With several animated components under one parent, every frame re-parses the props of all of them, not just the one being updated. For a Text, new props also mark its Yoga node dirty (ParagraphShadowNode::shouldNewRevisionDirtyMeasurement), so all of them are measured again. In a list of 30 JS-animated texts, on Android that was ~16,000 measurePreparedLayout calls per second (~40 per frame), and on iOS props parsing was 27–28% of the JS thread.

With this change, ShadowNodeFamily counts changes to nativeProps_DEPRECATED (setNativeProps_DEPRECATED, and UIManager::cloneNode when a React render updates them), and each shadow node records the count its props include. A clone with unchanged props keeps the source node's count; ShadowNode::clone applies nativeProps_DEPRECATED only when the node it clones is behind, exactly as before in every other case. So native props are still applied after setNativeProps and after a React render replaces a node's props; they are only no longer parsed again into props that already include them.

Changelog:

[GENERAL] [CHANGED] - Fabric no longer parses the props set by setNativeProps again on every clone of a component's shadow nodes

Test Plan:

  • New FabricUIManagerTest cases: after setNativeProps_DEPRECATED, a clone of a node whose props include them keeps the same props object, and a later setNativeProps_DEPRECATED is applied to the next clone; after a React render (cloneNode) replaces a node's props, the next clone applies the native props again. These C++ tests run internally; locally I compiled the test file against the real headers with a stub gtest (clang++ -fsyntax-only), as OSS CI doesn't run ReactCommon gtests.
  • yarn fantom with the setNativeProps reconciliation tests (Mounting-itest.js, ReactNativeElement-itest.js) and the Animated suites that drive JS animations through setNativeProps (AnimatedComponents, AnimatedComposition, AnimatedValue, TimingAnimation): 6 suites, 285 tests pass.
  • node scripts/clang-format.js --check reports no changes for the touched files.
  • No C++ API snapshot changes: the new ShadowNodeFamily field is excluded by _DEPRECATED and the new ShadowNode field is private.
  • On devices, a Playground screen of 30 Animated.Text rows with JS-driven loops (even rows animate color, odd rows fontSize), RNTester release builds:
    • Android, moto g57 power (Android 16), two APKs from the same local checkout (main at f7de203 with local benchmark screens and call counters), installed alternately, 20 s per run, three rounds. "Before" is a build with this check behind a runtime switch, turned off, so clone re-applies nativeProps_DEPRECATED every time as on main; "this PR" is this change as is:
before this PR change
measurePreparedLayout JNI calls per second 16,038 / 16,151 / 15,986 2,079 / 2,073 / 2,068 −87%
Text draws per second (animation throughput) 548 / 551 / 548 762 / 754 / 761 +39%
JS thread, 20 s (saturated in both) 20.5 / 20.7 / 20.6 s 19.0 / 18.9 / 18.9 s −8%, −33% per Text draw
Main thread, 20 s 3.52 / 3.44 / 3.50 s 3.24 / 3.10 / 3.20 s −9%

A control screen without setNativeProps (200 single-style numbers changing every 100 ms) is unchanged: JS time per text layout 0.45 → 0.44 ms, main thread 2.30 → 2.33 s.

  • iOS, iPhone 15 Pro (iOS 26.6.1), the same check behind a runtime switch in one build, Time Profiler for 12 s per run, two rounds each: props parsing (cloneProps and its callees) fell from 27–28% of the JS thread (1,608 / 2,708 ms) to 4.4–4.5% (158 / 208 ms). Total JS and main-thread times varied too much between runs on this phone to compare.

🤖 Generated with Claude Code

Once a component's props were set with setNativeProps, every clone of its
shadow nodes with unchanged props parsed nativeProps_DEPRECATED into new
props again, including the clones Yoga makes of every child of a node it
lays out. New props mark a Text's Yoga node dirty, so with JS-driven
Animated (which uses setNativeProps on Fabric) every sibling Text was
measured again in every frame.

The family now counts changes to nativeProps_DEPRECATED, and each node
records the count its props include. A clone applies nativeProps_DEPRECATED
only when the node it clones is behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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 29, 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 29, 2026
@hryhoriiK97

Copy link
Copy Markdown
Contributor Author

@javache could you take a look at this one when you have time? It stops re-parsing setNativeProps props every time a node is cloned, which JS-driven Animated does on every frame.

@javache javache 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.

Not a huge fan of this approach and doing the bookkeeping across shadownode and shadownodefamily. Could we reuse the existing ShadowNode revision_ metadata for this?

* Incremented whenever `nativeProps_DEPRECATED` changes, so a cloned node
* can tell whether its props already include them.
*/
mutable uint32_t nativePropsVersion_DEPRECATED{0};

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.

Instead of adding a new field here, should we make nativeProps_DEPRECATED a ptr to a struct with the props field and the version number?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@javache thanks for the review! I looked at revision_, but it only exists in debug builds, and it counts clones, not props changes, so it can't tell if a node's props already include the native props.

So I went with your struct idea: nativeProps_DEPRECATED now keeps the native props together with the props object they were last applied to. All the bookkeeping is in the family now, and ShadowNode has no new field. Done in 2e01b7b, could you take another look?

I measured it again on Android and the result is the same as before: about 87% fewer text measure calls.

Review feedback: instead of a version counter on both ShadowNodeFamily and
ShadowNode, `nativeProps_DEPRECATED` is now a pointer to a struct with the
props and the props object they were last applied to. `ShadowNode::clone`
skips parsing them when the node's `props_` is that object, so ShadowNode
no longer needs a field of its own. `setNativeProps_DEPRECATED` and
`UIManager::cloneNode` replace the struct, so the next clone applies the
new native props.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hryhoriiK97
hryhoriiK97 requested a review from javache November 11, 2026 16:33
@javache
javache requested a review from rubennorte November 11, 2026 17:16
*this,
{.props = componentDescriptor.cloneProps(
propsParserContext, props_, std::move(*propsOverride)),
{.props = props,

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.

Suggested change
{.props = props,
{.props = std::move(props),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done - 82c4383

auto props = componentDescriptor.cloneProps(
propsParserContext, props_, RawProps(nativeProps->props));
{
std::lock_guard<std::mutex> lock(family.nativePropsMutex);

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.

Can we make this a single block / lock? Could all of this logic live inside of if (family.nativeProps_DEPRECATED != nullptr)?

The risk is that we'd hold the lock during the cloneProps call, is that a concern?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@javache done in 82c4383. I don't think holding the lock during cloneProps is a problem: the mutex is per family, so it only blocks another thread working on the same node at the same time, and cloneProps never takes this lock again. UIManager::cloneNode already does mergeDynamicProps under it too. I kept cloneShadowNode outside the lock, though.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hryhoriiK97
hryhoriiK97 requested a review from javache November 13, 2026 11:43
@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Visual regression

No visual differences at dc41f3999b4b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hryhoriiK97
hryhoriiK97 requested a review from javache November 13, 2026 15:29
@hryhoriiK97

Copy link
Copy Markdown
Contributor Author

Visual regression

1 visual difference(s) at 82c4383571e0. Visual differences need maintainer approval. Review the report, then apply visual-change-approved.

ios button/default-styling/default: Changed, 38223 px (1.533%)

diff

Expected and Captured

The button/default-styling difference isn't related to this change: the same diff (38,223 px) shows up on unrelated PRs, for example #59888.

@meta-codesync

meta-codesync Bot commented Nov 13, 2026

Copy link
Copy Markdown

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

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

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.

2 participants