From 2c100e96efd998235eba9639d7dcd7e38fc32f76 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Wed, 21 Oct 2026 15:39:34 +0200 Subject: [PATCH 1/2] Defer cloning unchanged Yoga children until layout When a node gets a new list of children, `YogaLayoutableShadowNode::adoptYogaChild` clones every child that a previous revision of the node still owns, so that the new node owns the Yoga node of each child. Yoga already clones a child it doesn't own before it lays the child out, through `yogaNodeCloneCallbackConnector`, so the eager clone is only needed when the node is laid out. Behind the new `deferYogaChildCloningUntilLayout` feature flag (default `false`), such children stay shared with the previous revision, and Yoga clones the ones it lays out. Co-Authored-By: Claude Opus 5.5 --- .../View-deferYogaChildCloning-itest.js | 340 ++++++++++++++++++ .../featureflags/ReactNativeFeatureFlags.kt | 8 +- .../ReactNativeFeatureFlagsCxxAccessor.kt | 12 +- .../ReactNativeFeatureFlagsCxxInterop.kt | 4 +- .../ReactNativeFeatureFlagsDefaults.kt | 4 +- .../ReactNativeFeatureFlagsLocalAccessor.kt | 13 +- .../ReactNativeFeatureFlagsProvider.kt | 4 +- .../JReactNativeFeatureFlagsCxxInterop.cpp | 16 +- .../JReactNativeFeatureFlagsCxxInterop.h | 5 +- .../featureflags/ReactNativeFeatureFlags.cpp | 6 +- .../featureflags/ReactNativeFeatureFlags.h | 7 +- .../ReactNativeFeatureFlagsAccessor.cpp | 202 ++++++----- .../ReactNativeFeatureFlagsAccessor.h | 6 +- .../ReactNativeFeatureFlagsDefaults.h | 6 +- .../ReactNativeFeatureFlagsDynamicProvider.h | 11 +- .../ReactNativeFeatureFlagsProvider.h | 3 +- .../NativeReactNativeFeatureFlags.cpp | 7 +- .../NativeReactNativeFeatureFlags.h | 4 +- .../view/YogaLayoutableShadowNode.cpp | 11 +- .../ReactNativeFeatureFlags.config.js | 11 + .../featureflags/ReactNativeFeatureFlags.js | 7 +- .../specs/NativeReactNativeFeatureFlags.js | 3 +- 22 files changed, 578 insertions(+), 112 deletions(-) create mode 100644 packages/react-native/Libraries/Components/View/__tests__/View-deferYogaChildCloning-itest.js diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-deferYogaChildCloning-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-deferYogaChildCloning-itest.js new file mode 100644 index 000000000000..2584f43e0222 --- /dev/null +++ b/packages/react-native/Libraries/Components/View/__tests__/View-deferYogaChildCloning-itest.js @@ -0,0 +1,340 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @fantom_flags deferYogaChildCloningUntilLayout:* + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HostInstance} from 'react-native'; + +import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import {useState} from 'react'; +import {ScrollView, TextInput, View} from 'react-native'; + +// With `deferYogaChildCloningUntilLayout`, the unchanged children of a node +// that gets a new list of children stay shared with the previous revision of +// the node until Yoga lays the node out. These tests run with the flag off and +// on, and check that the mutations and layouts are the same. + +const CHILD_COUNT = 50; + +function rectOf(ref: { + current: HostInstance | null, +}): [number, number, number, number] { + const rect = nullthrows(ref.current).getBoundingClientRect(); + return [rect.x, rect.y, rect.width, rect.height]; +} + +function Rows({ + colorIndex, + color, + tallIndex, + refs, +}: { + colorIndex: number, + color: string, + tallIndex: number, + refs: ReadonlyArray>, +}): React.MixedElement { + return ( + + {refs.map((ref, i) => ( + + ))} + + ); +} + +describe('deferYogaChildCloningUntilLayout', () => { + it('updates one of many children without touching the others', () => { + const root = Fantom.createRoot(); + const refs = Array.from({length: CHILD_COUNT}, () => + React.createRef(), + ); + + Fantom.runTask(() => { + root.render( + , + ); + }); + root.takeMountingManagerLogs(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "View", nativeID: "row-20"}', + ]); + expect(rectOf(refs[20])).toEqual([0, 200, 390, 10]); + expect(rectOf(refs[CHILD_COUNT - 1])).toEqual([0, 490, 390, 10]); + + root.destroy(); + }); + + it('lays out shared children when a sibling changes size', () => { + const root = Fantom.createRoot(); + const refs = Array.from({length: CHILD_COUNT}, () => + React.createRef(), + ); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + // Commits that change no layout input, so the list is not laid out and + // its unchanged children stay shared. + for (const color of ['blue', 'green', 'yellow']) { + Fantom.runTask(() => { + root.render( + , + ); + }); + } + root.takeMountingManagerLogs(); + + // Row 10 grows by 20, which moves every row after it. + Fantom.runTask(() => { + root.render( + , + ); + }); + + expect(rectOf(refs[9])).toEqual([0, 90, 390, 10]); + expect(rectOf(refs[10])).toEqual([0, 100, 390, 30]); + expect(rectOf(refs[11])).toEqual([0, 130, 390, 10]); + expect(rectOf(refs[CHILD_COUNT - 1])).toEqual([0, 510, 390, 10]); + + const logs = root.takeMountingManagerLogs(); + expect(logs).toContain('Update {type: "View", nativeID: "row-10"}'); + expect(logs).toContain('Update {type: "View", nativeID: "row-11"}'); + expect(logs).toContain( + `Update {type: "View", nativeID: "row-${CHILD_COUNT - 1}"}`, + ); + expect(logs).not.toContain('Update {type: "View", nativeID: "row-9"}'); + + root.destroy(); + }); + + it('lays out a nested child whose parent was shared', () => { + const root = Fantom.createRoot(); + const innerRef = React.createRef(); + const groupRef = React.createRef(); + const afterRef = React.createRef(); + + function Tree({ + innerHeight, + color, + }: { + innerHeight: number, + color: string, + }): React.MixedElement { + return ( + + + + + + + ); + } + + Fantom.runTask(() => { + root.render(); + }); + // Only the last child changes, so the group stays shared. + Fantom.runTask(() => { + root.render(); + }); + // The group's child grows, which moves the last child. + Fantom.runTask(() => { + root.render(); + }); + + expect(rectOf(groupRef)).toEqual([0, 0, 390, 40]); + expect(rectOf(innerRef)).toEqual([0, 0, 390, 40]); + expect(rectOf(afterRef)).toEqual([0, 40, 390, 10]); + + root.destroy(); + }); + + it('keeps layouts correct when scrolling and then changing content', () => { + const root = Fantom.createRoot(); + const scrollRef = React.createRef(); + const refs = Array.from({length: CHILD_COUNT}, () => + React.createRef(), + ); + + function List({tallIndex}: {tallIndex: number}): React.MixedElement { + return ( + + + + {refs.map((ref, i) => ( + + ))} + + + ); + } + + Fantom.runTask(() => { + root.render(); + }); + + // Each scroll commits a state update of the ScrollView, which clones its + // ancestors with a new list of children. + Fantom.scrollTo(scrollRef, {x: 0, y: 100}); + Fantom.scrollTo(scrollRef, {x: 0, y: 150}); + + Fantom.runTask(() => { + root.render(); + }); + + // Rows are offset by the header (50) and the scroll offset (150). + expect(rectOf(refs[0])).toEqual([0, 50 - 150, 390, 30]); + expect(rectOf(refs[1])).toEqual([0, 80 - 150, 390, 10]); + expect(rectOf(refs[CHILD_COUNT - 1])).toEqual([0, 560 - 150, 390, 10]); + + root.destroy(); + }); + + it('keeps layouts correct when a node with shared children is hidden and shown', () => { + const root = Fantom.createRoot(); + const refs = Array.from({length: 3}, () => React.createRef()); + const afterRef = React.createRef(); + + function Tree({ + hidden, + color, + }: { + hidden: boolean, + color: string, + }): React.MixedElement { + return ( + +