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..41e37913e95c --- /dev/null +++ b/packages/react-native/Libraries/Components/View/__tests__/View-deferYogaChildCloning-itest.js @@ -0,0 +1,468 @@ +/** + * 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, Text, 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]; +} + +type Refs = ReadonlyArray>; + +function createRefs(count: number): Refs { + return Array.from({length: count}, () => React.createRef()); +} + +// Renders `steps` one commit at a time, then renders the last step into a +// fresh root, and checks that both roots end up with the same layout. +function expectSameLayoutAsFreshRender( + render: (step: number, refs: Refs) => React.MixedElement, + stepCount: number, + refCount: number, +): void { + const refs = createRefs(refCount); + const root = Fantom.createRoot(); + for (let step = 0; step < stepCount; step++) { + Fantom.runTask(() => { + root.render(render(step, refs)); + }); + } + + const freshRefs = createRefs(refCount); + const freshRoot = Fantom.createRoot(); + Fantom.runTask(() => { + freshRoot.render(render(stepCount - 1, freshRefs)); + }); + + expect(refs.map(rectOf)).toEqual(freshRefs.map(rectOf)); + + root.destroy(); + freshRoot.destroy(); +} + +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 ( + +