Skip to content

Commit 392b764

Browse files
authored
Avoid per-child dynamic_casts during Yoga layout
## Summary: `YogaLayoutableShadowNode` runs three `dynamic_cast`s 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](https://github.com/software-mansion/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 `Text`s 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](https://claude.com/claude-code) Differential Revision: D128421875 Pull Request resolved: #59643
1 parent 97f6997 commit 392b764

8 files changed

Lines changed: 110 additions & 10 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @fantom_flags enableFlexboxAutoMinSizeInStrictMode:*
9+
* @format
10+
*/
11+
12+
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
13+
14+
import type {HostInstance} from 'react-native';
15+
16+
import * as Fantom from '@react-native/fantom';
17+
import nullthrows from 'nullthrows';
18+
import * as React from 'react';
19+
import {createRef} from 'react';
20+
import {
21+
View,
22+
experimental_LayoutConformance as LayoutConformance,
23+
} from 'react-native';
24+
25+
type Mode = 'strict' | 'compatibility';
26+
27+
function renderRowWidth(mode: Mode, outerMode?: Mode): number {
28+
const root = Fantom.createRoot();
29+
const rowRef = createRef<HostInstance>();
30+
31+
const content = (
32+
<LayoutConformance mode={mode}>
33+
<View
34+
collapsable={false}
35+
style={{
36+
width: 60,
37+
height: 60,
38+
flexDirection: 'row',
39+
alignItems: 'center',
40+
}}>
41+
{/* With YGErrataStretchFlexBasis this row grows to its parent's
42+
width, even though its content takes no space. */}
43+
<View collapsable={false} ref={rowRef} style={{flexDirection: 'row'}}>
44+
<View collapsable={false} style={{height: 30, flexGrow: 1}} />
45+
</View>
46+
</View>
47+
</LayoutConformance>
48+
);
49+
50+
Fantom.runTask(() => {
51+
root.render(
52+
outerMode != null ? (
53+
<LayoutConformance mode={outerMode}>{content}</LayoutConformance>
54+
) : (
55+
content
56+
),
57+
);
58+
});
59+
60+
const width = nullthrows(rowRef.current).getBoundingClientRect().width;
61+
root.destroy();
62+
return width;
63+
}
64+
65+
test('strict mode lays out its subtree without the stretch flex-basis errata', () => {
66+
expect(renderRowWidth('strict')).toBe(0);
67+
});
68+
69+
// Compatibility is also the default, so check it under a strict subtree.
70+
test('compatibility mode inside strict mode brings the stretch flex-basis errata back', () => {
71+
expect(renderRowWidth('compatibility', 'strict')).toBe(60);
72+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#include "LayoutConformanceShadowNode.h"
9+
10+
namespace facebook::react {
11+
12+
// NOLINTNEXTLINE(facebook-hte-CArray,modernize-avoid-c-arrays)
13+
const char LayoutConformanceShadowNodeComponentName[] = "LayoutConformance";
14+
15+
} // namespace facebook::react

‎packages/react-native/ReactCommon/react/renderer/components/view/LayoutConformanceShadowNode.h‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
namespace facebook::react {
1616

1717
// NOLINTNEXTLINE(facebook-hte-CArray,modernize-avoid-c-arrays)
18-
constexpr const char LayoutConformanceShadowNodeComponentName[] = "LayoutConformance";
18+
extern const char LayoutConformanceShadowNodeComponentName[];
1919

2020
class LayoutConformanceShadowNode final : public ConcreteShadowNode<
2121
LayoutConformanceShadowNodeComponentName,

‎packages/react-native/ReactCommon/react/renderer/components/view/YogaLayoutableShadowNode.cpp‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -560,9 +560,15 @@ void YogaLayoutableShadowNode::configureYogaTree(
560560
}
561561

562562
YGErrata YogaLayoutableShadowNode::resolveErrata(YGErrata defaultErrata) const {
563-
if (auto layoutConformanceNode =
564-
dynamic_cast<const LayoutConformanceShadowNode*>(this)) {
565-
switch (layoutConformanceNode->getConcreteProps().mode) {
563+
// This runs for every child on every layout, and almost no node is a
564+
// LayoutConformance node, so a failing `dynamic_cast` would be the common
565+
// case. The class is final and its component name is defined once, in
566+
// LayoutConformanceShadowNode.cpp, so comparing component handles is
567+
// equivalent.
568+
if (getComponentHandle() == LayoutConformanceShadowNode::Handle()) {
569+
const auto& layoutConformanceNode =
570+
static_cast<const LayoutConformanceShadowNode&>(*this);
571+
switch (layoutConformanceNode.getConcreteProps().mode) {
566572
case LayoutConformance::Strict:
567573
// CSS Flexbox §4.5 automatic minimum sizing is the spec-compliant
568574
// behaviour, so strict conformance should adopt it — but its probe
@@ -829,7 +835,12 @@ Rect YogaLayoutableShadowNode::getContentBounds() const {
829835

830836
auto layoutMetricsWithOverflowInset = childNode.getLayoutMetrics();
831837
if (layoutMetricsWithOverflowInset.displayType != DisplayType::None) {
832-
auto viewChildNode = dynamic_cast<const ViewShadowNode*>(&childNode);
838+
// ViewShadowNode is final, so comparing component handles is equivalent
839+
// to a `dynamic_cast`, without its cost for every non-View child.
840+
auto viewChildNode =
841+
childNode.getComponentHandle() == ViewShadowNode::Handle()
842+
? static_cast<const ViewShadowNode*>(&childNode)
843+
: nullptr;
833844
auto hitSlop = viewChildNode != nullptr
834845
? viewChildNode->getConcreteProps().hitSlop
835846
: EdgeInsets{};
@@ -951,8 +962,8 @@ float YogaLayoutableShadowNode::yogaNodeBaselineCallbackConnector(
951962

952963
YogaLayoutableShadowNode& YogaLayoutableShadowNode::shadowNodeFromContext(
953964
YGNodeConstRef yogaNode) {
954-
return dynamic_cast<YogaLayoutableShadowNode&>(
955-
*static_cast<ShadowNode*>(YGNodeGetContext(yogaNode)));
965+
// The context is only set by the constructors of this class, to `this`.
966+
return *static_cast<YogaLayoutableShadowNode*>(YGNodeGetContext(yogaNode));
956967
}
957968

958969
yoga::Config& YogaLayoutableShadowNode::initializeYogaConfig(

‎private/react-native-fantom/tester/src/stubs/StubComponentRegistryFactory.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
#include <react/renderer/components/text/RawTextComponentDescriptor.h>
1717
#include <react/renderer/components/text/SelectableParagraphComponentDescriptor.h>
1818
#include <react/renderer/components/text/TextComponentDescriptor.h>
19+
#include <react/renderer/components/view/LayoutConformanceComponentDescriptor.h>
1920
#include <react/renderer/components/view/ViewComponentDescriptor.h>
2021

2122
namespace facebook::react {
@@ -27,6 +28,7 @@ inline ComponentRegistryFactory getDefaultComponentRegistryFactory()
2728
static auto providerRegistry = []() {
2829
auto providerRegistry = std::make_shared<ComponentDescriptorProviderRegistry>();
2930
providerRegistry->add(concreteComponentDescriptorProvider<ImageComponentDescriptor>());
31+
providerRegistry->add(concreteComponentDescriptorProvider<LayoutConformanceComponentDescriptor>());
3032
providerRegistry->add(concreteComponentDescriptorProvider<ParagraphComponentDescriptor>());
3133
providerRegistry->add(concreteComponentDescriptorProvider<ScrollViewComponentDescriptor>());
3234
providerRegistry->add(concreteComponentDescriptorProvider<RawTextComponentDescriptor>());

‎scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ struct facebook::hermes::ISetEventLoopControl : public facebook::jsi::ICast {
376376
const char facebook::react::AndroidTextInputComponentName[];
377377
const char facebook::react::ImageComponentName[];
378378
const char facebook::react::ImageManagerKey[];
379+
const char facebook::react::LayoutConformanceShadowNodeComponentName[];
379380
const char facebook::react::ModalHostViewComponentName[];
380381
const char facebook::react::ParagraphComponentName[];
381382
const char facebook::react::RawTextComponentName[];
@@ -386,7 +387,6 @@ const char facebook::react::TextComponentName[];
386387
const char facebook::react::ViewComponentName[];
387388
const facebook::react::EventTag facebook::react::EMPTY_EVENT_TAG;
388389
constexpr auto facebook::react::kSimpleThreadSafeCacheSizeCap;
389-
constexpr char facebook::react::LayoutConformanceShadowNodeComponentName[];
390390
constexpr char* const facebook::react::TextLayoutManagerKey;
391391
constexpr facebook::react::ReactNativeVersionType facebook::react::ReactNativeVersion;
392392
constexpr facebook::react::Tag facebook::react::kNoTag;

‎scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3186,6 +3186,7 @@ struct facebook::hermes::ISetEventLoopControl : public facebook::jsi::ICast {
31863186

31873187
const char facebook::react::ImageComponentName[];
31883188
const char facebook::react::ImageManagerKey[];
3189+
const char facebook::react::LayoutConformanceShadowNodeComponentName[];
31893190
const char facebook::react::ModalHostViewComponentName[];
31903191
const char facebook::react::ParagraphComponentName[];
31913192
const char facebook::react::RawTextComponentName[];
@@ -3197,7 +3198,6 @@ const char facebook::react::TextInputComponentName[];
31973198
const char facebook::react::ViewComponentName[];
31983199
const facebook::react::EventTag facebook::react::EMPTY_EVENT_TAG;
31993200
constexpr auto facebook::react::kSimpleThreadSafeCacheSizeCap;
3200-
constexpr char facebook::react::LayoutConformanceShadowNodeComponentName[];
32013201
constexpr char* const facebook::react::TextLayoutManagerKey;
32023202
constexpr facebook::react::ReactNativeVersionType facebook::react::ReactNativeVersion;
32033203
constexpr facebook::react::Tag facebook::react::kNoTag;

‎scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ struct facebook::hermes::ISetEventLoopControl : public facebook::jsi::ICast {
7373

7474
const char facebook::react::ImageComponentName[];
7575
const char facebook::react::ImageManagerKey[];
76+
const char facebook::react::LayoutConformanceShadowNodeComponentName[];
7677
const char facebook::react::ModalHostViewComponentName[];
7778
const char facebook::react::ParagraphComponentName[];
7879
const char facebook::react::RawTextComponentName[];
@@ -83,7 +84,6 @@ const char facebook::react::TextComponentName[];
8384
const char facebook::react::ViewComponentName[];
8485
const facebook::react::EventTag facebook::react::EMPTY_EVENT_TAG;
8586
constexpr auto facebook::react::kSimpleThreadSafeCacheSizeCap;
86-
constexpr char facebook::react::LayoutConformanceShadowNodeComponentName[];
8787
constexpr char* const facebook::react::TextLayoutManagerKey;
8888
constexpr facebook::react::ReactNativeVersionType facebook::react::ReactNativeVersion;
8989
constexpr facebook::react::Tag facebook::react::kNoTag;

0 commit comments

Comments
 (0)