Skip to content

Avoid per-child dynamic_casts during Yoga layout - #59643

Merged
meta-codesync[bot] merged 2 commits into
react:mainfrom
hryhoriiK97:yoga-layout-skip-dynamic-casts
Oct 30, 2026
Merged

meta-codesync[bot] merged 2 commits into
react:mainfrom
hryhoriiK97:yoga-layout-skip-dynamic-casts

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

YogaLayoutableShadowNode runs three dynamic_casts for children on every layout pass:

  • resolveErrata checks dynamic_cast<const LayoutConformanceShadowNode *>(this). configureYogaTree calls it for every child to decide whether the child has to be configured again, so it runs on every layout, and it fails for almost every node, which is the slowest case of a dynamic_cast.
  • getContentBounds checks dynamic_cast<const ViewShadowNode *> for every child to read its hitSlop. It fails for every child that isn't a View (Text, ScrollView, Image, …).
  • shadowNodeFromContext gets the shadow node back from a Yoga node's context with a dynamic_cast. It runs for every child in layout() and getContentBounds(), and in Yoga's measure, baseline and clone callbacks.

Current behavior. In Time Profiler traces of JS-driven updates on an iPhone, __dynamic_cast took 11–18% of the JS thread, most of it inside layoutTree, and the type comparisons in those traces go through strcmp.

With this change. LayoutConformanceShadowNode and ViewShadowNode are both final, and their nodes are only created by descriptors that report the class's own Handle(), so comparing getComponentHandle() with that handle gives the same answer as the dynamic_cast; a static_cast follows. The Yoga node's context is only set by this class's two constructors, to this, so shadowNodeFromContext recovers it with a static_cast of the stored pointer. The behavior doesn't change.

Changelog:

[GENERAL] [CHANGED] - Yoga layout no longer uses dynamic_cast for every child to check its type

Test Plan:

  • The pinned clang-format passes on the changed file, and it compiles for iOS (arm64).
  • yarn fantom (full suite, tester rebuilt with cmake from this change, then from main): no test fails with this change that doesn't also fail on main in the same local environment. The local failures (LogBox and expect snapshot formatting, Text-itest, EventTimingAPI-itest) happen with and without this change.
  • Measurement. RNTester built with xcodebuild in Release and run on a physical iPhone 15 Pro (iOS 26.6.1). Both arms are the same binary, with this change switched by an environment variable read once. 8 launches in the order before, after, after, before, after, before, before, after; in each, 12 s of Time Profiler per scenario, counting JS-thread samples. Interactions were driven by Argent on the physical iPhone. The phone warmed up during the runs, so the totals of the same arm vary by up to 30%; the time in dynamic_cast is the most direct number, and the medians are given for context.

RNTester Playground, 30 Texts animated with Animated (JS driver), medians of 4 launches per arm:

Scenario before this PR change
Color animated dynamic_cast inside layout, per 12 s 317 ms 1.5 ms −99.5%
dynamic_cast, share of the JS thread 17.0% 4.9%
Yoga layout (layoutTree), per 12 s 1,144 ms 836 ms −26.9%
JS thread, per 12 s 2,454 ms 2,154 ms −12.2%
Color and font size animated dynamic_cast inside layout, per 12 s 338 ms 1.5 ms −99.6%
dynamic_cast, share of the JS thread 11.1% 3.0%
Yoga layout (layoutTree), per 12 s 2,618 ms 2,166 ms −17.3%
JS thread, per 12 s 4,098 ms 3,562 ms −13.1%

The dynamic_cast time left with this change (about 3–5% of the JS thread) comes from other places, such as adoptYogaChild and the shadow node constructors, which this PR doesn't change.

On Android these casts cost much less, since there the type comparison compares pointers: in the same color-animation scenario on an Android emulator, dynamic_cast inside layout went from 1.5% to 0.2% of the JS thread.

🤖 Generated with Claude Code

`resolveErrata` and `getContentBounds` check whether a child is a
`LayoutConformanceShadowNode` or a `ViewShadowNode` with a `dynamic_cast`,
which fails for almost every node. Both classes are final, so compare the
component handle instead. `shadowNodeFromContext` casts the Yoga node's
context, which only this class's constructors set (to `this`), so a
`static_cast` recovers it.

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
@meta-codesync

meta-codesync Bot commented Oct 29, 2026

Copy link
Copy Markdown

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

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

This breaks some layout tests - see

<LayoutConformanceExample testID="layout-conformance-no-outer-context" />

`LayoutConformanceShadowNodeComponentName` was a `constexpr` array in the
header, so every translation unit had its own copy. `Handle()` is the
address of that name, so `LayoutConformanceShadowNode::Handle()` in
YogaLayoutableShadowNode.cpp never matched the handle of the registered
component, and strict and compatibility errata were never applied.
Declare it `extern` and define it in a .cpp, like View and the other core
components.

Add a Fantom test for strict and compatibility mode, and register
LayoutConformance in the Fantom tester.

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

Copy link
Copy Markdown
Contributor Author

This breaks some layout tests - see

<LayoutConformanceExample testID="layout-conformance-no-outer-context" />

@javache thanks! The name was a constexpr in the header, so every .cpp got its own copy and the handle never matched. I moved it to a .cpp like View does and added a Fantom test for strict mode. RNTester looks like main again, and the perf win is the same.

@hryhoriiK97
hryhoriiK97 requested a review from javache October 30, 2026 11:17

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

Review automatically exported from Phabricator review in Meta.

@meta-codesync
meta-codesync Bot merged commit 392b764 into react:main Oct 30, 2026
98 of 99 checks passed
@react-native-bot react-native-bot added the Merged This PR has been merged. label Oct 30, 2026
@react-native-bot

Copy link
Copy Markdown
Collaborator

This pull request was successfully merged by @hryhoriiK97 in 392b764

When will my fix make it into a release? | How to file a pick request?

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. Merged This PR has been merged. 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.

4 participants