From 566d766038687f058c6ec200d161d335deb2bb7d Mon Sep 17 00:00:00 2001 From: Samuel Susla Date: Wed, 5 Feb 2025 05:33:39 -0800 Subject: [PATCH] introduce Fabric View Culling (#49198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: changelog: [internal] The work done on the main thread should scale with what is on the screen. React Native shouldn’t block the main thread for off screen elements that do not affect what is shown to the end user. When React schedules a commit, only views needed to achieve a screen full of content should be materialised and added to the host platform’s view hierarchy. With Fabric View Culling, views that do not contribute pixels to the screen will not materialize and updates to them will be skipped. React Native will focus system resources on what is visible to the end user. Fabric View Culling maximises benefits from view recycling. Each UI element such as text, image, or video is recycled individually. As soon as an item goes off screen, it can be reused anywhere in the UI and pieced together with other items to create new UI elements. Such recycling reduces the need of having multiple view types and improves memory usage and scroll performance. In the example bellow, view B will not be mounted because the user can't see it. {F1974949953} The difference in number of allocated views: Please note, the screenshots below are from Xcode View Hierarchy debugger. To show how many views are allocated in memory, I disabled [removeClippedSubviews](https://reactnative.dev/docs/scrollview#removeclippedsubviews) flag globally. |Before|After: | {F1974949979}| {F1974949981} # Disclaimer, this is not a complete implementation This implementation is not complete and it is missing to handle edge cases. Things that are missing: - Transform style is not taken into account. - removeClippedSubviews is not respected. Fabric View Culling happens unconditionally for every scroll view. - Fabric View Culling does not respect when ScrollView has overflow set to visible. - Fabric View Culling is only performant enough on iOS. - [enableSynchronousStateUpdates](https://github.com/facebook/react-native/blob/main/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js#L248) must be enabled for Fabric View Culling to work correctly. Differential Revision: D63458372 --- .../__tests__/ScrollView-viewCulling-itest.js | 660 ++++++++++++++++++ .../View/RCTViewComponentView.mm | 12 +- .../renderer/mounting/Differentiator.cpp | 324 +++++++-- .../react/renderer/mounting/Differentiator.h | 5 +- .../react/renderer/mounting/stubs/stubs.cpp | 4 +- 5 files changed, 942 insertions(+), 63 deletions(-) create mode 100644 packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-viewCulling-itest.js diff --git a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-viewCulling-itest.js b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-viewCulling-itest.js new file mode 100644 index 00000000000000..39f0992271acbb --- /dev/null +++ b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-viewCulling-itest.js @@ -0,0 +1,660 @@ +/** + * 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. + * + * @flow strict-local + * @format + * @oncall react_native + * @fantom_flags enableAccessToHostTreeInFabric:true + * @fantom_flags enableViewCulling:true + * @fantom_flags enableSynchronousStateUpdates:true + */ + +import '../../../Core/InitializeCore.js'; +import ensureInstance from '../../../../src/private/utilities/ensureInstance'; +import ReactNativeElement from '../../../../src/private/webapis/dom/nodes/ReactNativeElement'; +import View from '../../View/View'; +import ScrollView from '../ScrollView'; +import Fantom from '@react-native/fantom'; +import * as React from 'react'; + +test('basic culling', () => { + const root = Fantom.createRoot({viewportWidth: 100, viewportHeight: 100}); + let maybeNode; + + Fantom.runTask(() => { + root.render( + { + maybeNode = node; + }}> + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "child"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "child"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + const element = ensureInstance(maybeNode, ReactNativeElement); + + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 60, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "child"}', + 'Delete {type: "View", nativeID: "child"}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Delete {type: "View", nativeID: (N/A)}', + 'Update {type: "ScrollView", nativeID: (N/A)}', + ]); + + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 0, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "child"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "child"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + ]); +}); + +test('recursive culling', () => { + const root = Fantom.createRoot({viewportHeight: 100, viewportWidth: 100}); + let maybeNode; + + Fantom.runTask(() => { + root.render( + { + maybeNode = node; + }}> + + + + + + + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "element A"}', + 'Create {type: "View", nativeID: "child AA"}', + 'Create {type: "View", nativeID: "child AB"}', + 'Insert {type: "View", parentNativeID: "element A", index: 0, nativeID: "child AA"}', + 'Insert {type: "View", parentNativeID: "element A", index: 1, nativeID: "child AB"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + const element = ensureInstance(maybeNode, ReactNativeElement); + + // === Scroll down to the edge of child AA === + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 30, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + ]); + + // === Scroll down past child AA === + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 36, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: "element A", index: 0, nativeID: "child AA"}', + 'Delete {type: "View", nativeID: "child AA"}', + ]); + + // === Scroll down past child AB === + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 51, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: "element A", index: 0, nativeID: "child AB"}', + 'Delete {type: "View", nativeID: "child AB"}', + ]); + + // === Scroll down past element A === + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 56, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + 'Delete {type: "View", nativeID: "element A"}', + ]); + + // Scroll element B into viewport. Just child BA should be created. + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 155, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "element B"}', + 'Create {type: "View", nativeID: "child BA"}', + 'Insert {type: "View", parentNativeID: "element B", index: 0, nativeID: "child BA"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element B"}', + ]); + + // Scroll child BA into viewport. + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 165, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "child BB"}', + 'Insert {type: "View", parentNativeID: "element B", index: 1, nativeID: "child BB"}', + ]); + + // Scroll back to start + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 0, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: "element B", index: 1, nativeID: "child BB"}', + 'Remove {type: "View", parentNativeID: "element B", index: 0, nativeID: "child BA"}', + 'Delete {type: "View", nativeID: "child BA"}', + 'Delete {type: "View", nativeID: "child BB"}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element B"}', + 'Delete {type: "View", nativeID: "element B"}', + 'Create {type: "View", nativeID: "element A"}', + 'Create {type: "View", nativeID: "child AA"}', + 'Create {type: "View", nativeID: "child AB"}', + 'Insert {type: "View", parentNativeID: "element A", index: 0, nativeID: "child AA"}', + 'Insert {type: "View", parentNativeID: "element A", index: 1, nativeID: "child AB"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + ]); + + // Scroll past element A + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 85, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: "element A", index: 1, nativeID: "child AB"}', + 'Remove {type: "View", parentNativeID: "element A", index: 0, nativeID: "child AA"}', + 'Delete {type: "View", nativeID: "child AA"}', + 'Delete {type: "View", nativeID: "child AB"}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + 'Delete {type: "View", nativeID: "element A"}', + ]); +}); + +test('recursive culling when initial offset is negative', () => { + const root = Fantom.createRoot({viewportHeight: 874, viewportWidth: 402}); + let maybeNode; + + Fantom.runTask(() => { + root.render( + { + maybeNode = node; + }}> + + + + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + const element = ensureInstance(maybeNode, ReactNativeElement); + + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 0, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "child A"}', + 'Create {type: "View", nativeID: "child B"}', + 'Create {type: "View", nativeID: "child BA"}', + 'Create {type: "View", nativeID: "child BB"}', + 'Insert {type: "View", parentNativeID: "child B", index: 0, nativeID: "child BA"}', + 'Insert {type: "View", parentNativeID: "child B", index: 1, nativeID: "child BB"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "child A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 1, nativeID: "child B"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + ]); +}); + +test('deep nesting', () => { + const root = Fantom.createRoot({viewportHeight: 100, viewportWidth: 100}); + let maybeNode; + + Fantom.runTask(() => { + root.render( + { + maybeNode = node; + }}> + + + + + + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "element A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + const element = ensureInstance(maybeNode, ReactNativeElement); + + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 40, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "element B"}', + 'Create {type: "View", nativeID: "child BA"}', + 'Create {type: "View", nativeID: "child BAA"}', + 'Insert {type: "View", parentNativeID: "child BA", index: 0, nativeID: "child BAA"}', + 'Insert {type: "View", parentNativeID: "element B", index: 0, nativeID: "child BA"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 1, nativeID: "element B"}', + ]); + + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 150, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + 'Delete {type: "View", nativeID: "element A"}', + 'Create {type: "View", nativeID: "child BAB"}', + 'Insert {type: "View", parentNativeID: "child BA", index: 1, nativeID: "child BAB"}', + ]); +}); + +test('adding new item into area that is not culled', () => { + const root = Fantom.createRoot({viewportHeight: 100, viewportWidth: 100}); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "element A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + Fantom.runTask(() => { + root.render( + + + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Create {type: "View", nativeID: "child AA"}', + 'Insert {type: "View", parentNativeID: "element A", index: 0, nativeID: "child AA"}', + ]); +}); + +test('adding new item into area that is culled', () => { + const root = Fantom.createRoot({viewportHeight: 100, viewportWidth: 100}); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "element B"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element B"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + Fantom.runTask(() => { + root.render( + + + + , + ); + }); + + // element B is updated but it should be inconsequential. + // Differentiator generates an update for it because Yoga cloned + // shadow node backing element B. + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "View", nativeID: "element B"}', + ]); +}); + +test('initial render', () => { + let maybeNode; + const root = Fantom.createRoot({viewportHeight: 100, viewportWidth: 100}); + + Fantom.runTask(() => { + root.render( + { + maybeNode = node; + }} + style={{height: 100, width: 100}}> + + + + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "element A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + const element = ensureInstance(maybeNode, ReactNativeElement); + + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 100, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element A"}', + 'Delete {type: "View", nativeID: "element A"}', + 'Create {type: "View", nativeID: "element B"}', + 'Create {type: "View", nativeID: "child BA"}', + 'Create {type: "View", nativeID: "child BB"}', + 'Insert {type: "View", parentNativeID: "element B", index: 0, nativeID: "child BA"}', + 'Insert {type: "View", parentNativeID: "element B", index: 1, nativeID: "child BB"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element B"}', + ]); +}); + +test('unmounting culled elements', () => { + const root = Fantom.createRoot({viewportWidth: 100, viewportHeight: 100}); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + Fantom.runTask(() => { + root.render(<>); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Remove {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + 'Delete {type: "ScrollView", nativeID: (N/A)}', + ]); +}); + +// TODO: only elements in ScrollView are culled. +test('basic culling smaller ScrollView', () => { + let maybeNode; + const root = Fantom.createRoot({viewportWidth: 100, viewportHeight: 100}); + + Fantom.runTask(() => { + root.render( + { + maybeNode = node; + }} + style={{height: 50, width: 50, marginTop: 25}}> + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "ScrollView", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: "element 1"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element 1"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + + const element = ensureInstance(maybeNode, ReactNativeElement); + + Fantom.runOnUIThread(() => { + Fantom.scrollTo(element, { + x: 0, + y: 11, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element 1"}', + 'Delete {type: "View", nativeID: "element 1"}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Delete {type: "View", nativeID: (N/A)}', + 'Update {type: "ScrollView", nativeID: (N/A)}', + ]); +}); + +test('views are not culled when outside of viewport', () => { + const root = Fantom.createRoot({viewportWidth: 100, viewportHeight: 100}); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "RootView", nativeID: (root)}', + 'Create {type: "View", nativeID: "child"}', + 'Insert {type: "View", parentNativeID: (root), index: 0, nativeID: "child"}', + ]); +}); diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index e1d731ae17ee07..7552cee49fac3e 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -64,6 +64,7 @@ - (instancetype)initWithFrame:(CGRect)frame _reactSubviews = [NSMutableArray new]; self.multipleTouchEnabled = YES; _useCustomContainerView = NO; + _removeClippedSubviews = NO; } return self; } @@ -229,10 +230,13 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & needsInvalidateLayer = YES; } - if (oldViewProps.removeClippedSubviews != newViewProps.removeClippedSubviews) { - _removeClippedSubviews = newViewProps.removeClippedSubviews; - if (_removeClippedSubviews && self.currentContainerView.subviews.count > 0) { - _reactSubviews = [NSMutableArray arrayWithArray:self.currentContainerView.subviews]; + // Disable `removeClippedSubviews` when Fabric View Culling is enabled. + if (!ReactNativeFeatureFlags::enableViewCulling()) { + if (oldViewProps.removeClippedSubviews != newViewProps.removeClippedSubviews) { + _removeClippedSubviews = newViewProps.removeClippedSubviews; + if (_removeClippedSubviews && self.currentContainerView.subviews.count > 0) { + _reactSubviews = [NSMutableArray arrayWithArray:self.currentContainerView.subviews]; + } } } diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp b/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp index f38e0fa7109a34..1069cb0742dbea 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp +++ b/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -263,10 +264,10 @@ static void sliceChildShadowNodeViewPairsRecursively( size_t& startOfStaticIndex, ViewNodePairScope& scope, Point layoutOffset, - const ShadowNode& shadowNode) { + const ShadowNode& shadowNode, + Rect viewportFrame) { for (const auto& sharedChildShadowNode : shadowNode.getChildren()) { auto& childShadowNode = *sharedChildShadowNode; - #ifndef ANDROID // T153547836: Disabled on Android because the mounting infrastructure // is not fully ready yet. @@ -274,9 +275,34 @@ static void sliceChildShadowNodeViewPairsRecursively( continue; } #endif - auto shadowView = ShadowView(childShadowNode); + + if (ReactNativeFeatureFlags::enableViewCulling()) { + if (shadowView.layoutMetrics != EmptyLayoutMetrics && + viewportFrame.size.width != 0) { + auto doesIntersect = + Rect::intersect( + viewportFrame, + shadowView.layoutMetrics.getOverflowInsetFrame()) != Rect{}; + if (!doesIntersect) { + continue; // Culling. + } + } + } + auto origin = layoutOffset; + auto copyViewportFrame = viewportFrame; + + if (auto scrollViewNode = + dynamic_cast(&childShadowNode)) { + copyViewportFrame.origin = {}; + copyViewportFrame.origin -= + scrollViewNode->getContentOriginOffset(/* includeTransform */ true); + copyViewportFrame.size = scrollViewNode->getLayoutMetrics().frame.size; + } else { + copyViewportFrame.origin -= shadowView.layoutMetrics.frame.origin; + } + if (shadowView.layoutMetrics != EmptyLayoutMetrics) { origin += shadowView.layoutMetrics.frame.origin; shadowView.layoutMetrics.frame.origin += layoutOffset; @@ -317,14 +343,24 @@ static void sliceChildShadowNodeViewPairsRecursively( startOfStaticIndex++; if (areChildrenFlattened) { sliceChildShadowNodeViewPairsRecursively( - pairList, startOfStaticIndex, scope, origin, childShadowNode); + pairList, + startOfStaticIndex, + scope, + origin, + childShadowNode, + copyViewportFrame); } } else { pairList.push_back(&scope.back()); if (areChildrenFlattened) { size_t pairListSize = pairList.size(); sliceChildShadowNodeViewPairsRecursively( - pairList, pairListSize, scope, origin, childShadowNode); + pairList, + pairListSize, + scope, + origin, + childShadowNode, + copyViewportFrame); } } } @@ -334,7 +370,8 @@ std::vector sliceChildShadowNodeViewPairs( const ShadowViewNodePair& shadowNodePair, ViewNodePairScope& scope, bool allowFlattened, - Point layoutOffset) { + Point layoutOffset, + Rect viewportFrame) { const auto& shadowNode = *shadowNodePair.shadowNode; auto pairList = std::vector{}; @@ -346,7 +383,12 @@ std::vector sliceChildShadowNodeViewPairs( size_t startOfStaticIndex = 0; sliceChildShadowNodeViewPairsRecursively( - pairList, startOfStaticIndex, scope, layoutOffset, shadowNode); + pairList, + startOfStaticIndex, + scope, + layoutOffset, + shadowNode, + viewportFrame); // Sorting pairs based on `orderIndex` if needed. reorderInPlaceIfNeeded(pairList); @@ -369,12 +411,14 @@ static std::vector sliceChildShadowNodeViewPairsFromViewNodePair( const ShadowViewNodePair& shadowViewNodePair, ViewNodePairScope& scope, - bool allowFlattened = false) { + bool allowFlattened, + Rect viewportFrame) { return sliceChildShadowNodeViewPairs( shadowViewNodePair, scope, allowFlattened, - shadowViewNodePair.contextOrigin); + shadowViewNodePair.contextOrigin, + viewportFrame); } /* @@ -409,7 +453,9 @@ static void calculateShadowViewMutations( ShadowViewMutation::List& mutations, Tag parentTag, std::vector&& oldChildPairs, - std::vector&& newChildPairs); + std::vector&& newChildPairs, + Rect oldViewportFrame = {}, + Rect newViewportFrame = {}); struct OrderedMutationInstructionContainer { ShadowViewMutation::List createMutations{}; @@ -428,7 +474,9 @@ static void updateMatchedPairSubtrees( std::vector& oldChildPairs, Tag parentTag, const ShadowViewNodePair& oldPair, - const ShadowViewNodePair& newPair); + const ShadowViewNodePair& newPair, + Rect oldViewportFrame, + Rect newViewportFrame); static void updateMatchedPair( OrderedMutationInstructionContainer& mutationContainer, @@ -446,8 +494,10 @@ static void calculateShadowViewMutationsFlattener( TinyMap& unvisitedOtherNodes, const ShadowViewNodePair& node, Tag parentTagForUpdate, - TinyMap* parentSubVisitedOtherNewNodes = nullptr, - TinyMap* parentSubVisitedOtherOldNodes = nullptr); + TinyMap* parentSubVisitedOtherNewNodes, + TinyMap* parentSubVisitedOtherOldNodes, + Rect oldViewportFrame, + Rect newViewportFrame); /** * Updates the subtrees of any matched ShadowViewNodePair. This handles @@ -464,7 +514,9 @@ static void updateMatchedPairSubtrees( std::vector& oldChildPairs, Tag parentTag, const ShadowViewNodePair& oldPair, - const ShadowViewNodePair& newPair) { + const ShadowViewNodePair& newPair, + Rect oldViewportFrame, + Rect newViewportFrame) { // Are we flattening or unflattening either one? If node was // flattened in both trees, there's no change, just continue. if (oldPair.flattened && newPair.flattened) { @@ -493,7 +545,11 @@ static void updateMatchedPairSubtrees( parentTag, newRemainingPairs, oldPair, - oldPair.shadowView.tag); + oldPair.shadowView.tag, + nullptr, + nullptr, + oldViewportFrame, + newViewportFrame); } // Unflattening else { @@ -504,8 +560,8 @@ static void updateMatchedPairSubtrees( // relative order. The reason for this is because of flattening // + zIndex: the children could be listed before the parent, // interwoven with children from other nodes, etc. - auto oldFlattenedNodes = - sliceChildShadowNodeViewPairsFromViewNodePair(oldPair, scope, true); + auto oldFlattenedNodes = sliceChildShadowNodeViewPairsFromViewNodePair( + oldPair, scope, true, oldViewportFrame); for (size_t i = 0, j = 0; i < oldChildPairs.size() && j < oldFlattenedNodes.size(); i++) { @@ -524,7 +580,11 @@ static void updateMatchedPairSubtrees( parentTag, unvisitedOldChildPairs, newPair, - parentTag); + parentTag, + nullptr, + nullptr, + oldViewportFrame, + newViewportFrame); // If old nodes were not visited, we know that we can delete // them now. They will be removed from the hierarchy by the @@ -551,13 +611,37 @@ static void updateMatchedPairSubtrees( // Update subtrees if View is not flattened, and if node addresses // are not equal - if (oldPair.shadowNode != newPair.shadowNode) { + if (oldPair.shadowNode != newPair.shadowNode || + oldViewportFrame != newViewportFrame) { + if (auto newScrollViewShadowNode = + dynamic_cast(newPair.shadowNode); + auto oldScrollViewShadowNode = + dynamic_cast(oldPair.shadowNode)) { + oldViewportFrame.origin = {}; + oldViewportFrame.origin -= + oldScrollViewShadowNode->getContentOriginOffset( + /* includeTransform */ true); + oldViewportFrame.size = + oldScrollViewShadowNode->getLayoutMetrics().frame.size; + + newViewportFrame.origin = {}; + newViewportFrame.origin -= + newScrollViewShadowNode->getContentOriginOffset( + /* includeTransform */ true); + newViewportFrame.size = + newScrollViewShadowNode->getLayoutMetrics().frame.size; + } else { + oldViewportFrame.origin -= oldPair.shadowView.layoutMetrics.frame.origin; + newViewportFrame.origin -= newPair.shadowView.layoutMetrics.frame.origin; + } + ViewNodePairScope innerScope{}; - auto oldGrandChildPairs = - sliceChildShadowNodeViewPairsFromViewNodePair(oldPair, innerScope); - auto newGrandChildPairs = - sliceChildShadowNodeViewPairsFromViewNodePair(newPair, innerScope); + auto oldGrandChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( + oldPair, innerScope, false, oldViewportFrame); + auto newGrandChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( + newPair, innerScope, false, newViewportFrame); const size_t newGrandChildPairsSize = newGrandChildPairs.size(); + calculateShadowViewMutations( innerScope, *(newGrandChildPairsSize != 0u @@ -565,7 +649,9 @@ static void updateMatchedPairSubtrees( : &mutationContainer.destructiveDownwardMutations), oldPair.shadowView.tag, std::move(oldGrandChildPairs), - std::move(newGrandChildPairs)); + std::move(newGrandChildPairs), + oldViewportFrame, + newViewportFrame); } } @@ -679,10 +765,13 @@ static void calculateShadowViewMutationsFlattener( const ShadowViewNodePair& node, Tag parentTagForUpdate, TinyMap* parentSubVisitedOtherNewNodes, - TinyMap* parentSubVisitedOtherOldNodes) { + TinyMap* parentSubVisitedOtherOldNodes, + Rect oldViewportFrame, + Rect newViewportFrame) { // Step 1: iterate through entire tree std::vector treeChildren = - sliceChildShadowNodeViewPairsFromViewNodePair(node, scope); + sliceChildShadowNodeViewPairsFromViewNodePair( + node, scope, false, newViewportFrame); DEBUG_LOGS({ LOG(ERROR) << "Differ Flattener: " @@ -885,9 +974,11 @@ static void calculateShadowViewMutationsFlattener( mutationContainer.downwardMutations, newTreeNodePair.shadowView.tag, sliceChildShadowNodeViewPairsFromViewNodePair( - oldTreeNodePair, innerScope), + oldTreeNodePair, innerScope, false, oldViewportFrame), sliceChildShadowNodeViewPairsFromViewNodePair( - newTreeNodePair, innerScope)); + newTreeNodePair, innerScope, false, newViewportFrame), + oldViewportFrame, + newViewportFrame); } } else if (oldTreeNodePair.flattened != newTreeNodePair.flattened) { // We need to handle one of the children being flattened or @@ -912,14 +1003,18 @@ static void calculateShadowViewMutationsFlattener( ? oldTreeNodePair.shadowView.tag : parentTag), subVisitedNewMap, - subVisitedOldMap); + subVisitedOldMap, + oldViewportFrame, + newViewportFrame); } else { // Get flattened nodes from either new or old tree auto flattenedNodes = sliceChildShadowNodeViewPairsFromViewNodePair( (childReparentMode == ReparentMode::Flatten ? newTreeNodePair : oldTreeNodePair), scope, - true); + true, + childReparentMode == ReparentMode::Flatten ? newViewportFrame + : oldViewportFrame); // Construct unvisited nodes map auto unvisitedRecursiveChildPairs = TinyMap{}; @@ -956,7 +1051,9 @@ static void calculateShadowViewMutationsFlattener( ? oldTreeNodePair.shadowView.tag : parentTag), subVisitedNewMap, - subVisitedOldMap); + subVisitedOldMap, + oldViewportFrame, + newViewportFrame); } // Flatten parent, unflatten child else { @@ -974,7 +1071,9 @@ static void calculateShadowViewMutationsFlattener( ? oldTreeNodePair.shadowView.tag : parentTag), subVisitedNewMap, - subVisitedOldMap); + subVisitedOldMap, + oldViewportFrame, + newViewportFrame); // If old nodes were not visited, we know that we can delete them // now. They will be removed from the hierarchy by the outermost @@ -1070,8 +1169,10 @@ static void calculateShadowViewMutationsFlattener( mutationContainer.destructiveDownwardMutations, treeChildPair.shadowView.tag, sliceChildShadowNodeViewPairsFromViewNodePair( - treeChildPair, innerScope), - {}); + treeChildPair, innerScope, false, newViewportFrame), + {}, + oldViewportFrame, + newViewportFrame); } } else { mutationContainer.createMutations.push_back( @@ -1085,7 +1186,9 @@ static void calculateShadowViewMutationsFlattener( treeChildPair.shadowView.tag, {}, sliceChildShadowNodeViewPairsFromViewNodePair( - treeChildPair, innerScope)); + treeChildPair, innerScope, false, newViewportFrame), + oldViewportFrame, + newViewportFrame); } } } @@ -1096,7 +1199,9 @@ static void calculateShadowViewMutations( ShadowViewMutation::List& mutations, Tag parentTag, std::vector&& oldChildPairs, - std::vector&& newChildPairs) { + std::vector&& newChildPairs, + Rect oldViewportFrame, + Rect newViewportFrame) { if (oldChildPairs.empty() && newChildPairs.empty()) { return; } @@ -1152,13 +1257,45 @@ static void calculateShadowViewMutations( // Recursively update tree if ShadowNode pointers are not equal if (!oldChildPair.flattened && - oldChildPair.shadowNode != newChildPair.shadowNode) { + (oldChildPair.shadowNode != newChildPair.shadowNode || + oldViewportFrame != newViewportFrame)) { + auto oldViewportFrameCopy = oldViewportFrame; + auto newViewportFrameCopy = newViewportFrame; + + if (auto newScrollViewShadowNode = + dynamic_cast( + newChildPair.shadowNode); + auto oldScrollViewShadowNode = + dynamic_cast( + oldChildPair.shadowNode)) { + oldViewportFrameCopy.origin = {}; + oldViewportFrameCopy.origin -= + oldScrollViewShadowNode->getContentOriginOffset( + /* includeTransform */ true); + oldViewportFrameCopy.size = + oldScrollViewShadowNode->getLayoutMetrics().frame.size; + + newViewportFrameCopy.origin = {}; + newViewportFrameCopy.origin -= + newScrollViewShadowNode->getContentOriginOffset( + /* includeTransform */ true); + newViewportFrameCopy.size = + newScrollViewShadowNode->getLayoutMetrics().frame.size; + } else { + oldViewportFrameCopy.origin -= + oldChildPair.shadowView.layoutMetrics.frame.origin; + newViewportFrameCopy.origin -= + newChildPair.shadowView.layoutMetrics.frame.origin; + } + ViewNodePairScope innerScope{}; auto oldGrandChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( - oldChildPair, innerScope); + oldChildPair, innerScope, false, oldViewportFrameCopy); auto newGrandChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( - newChildPair, innerScope); + newChildPair, innerScope, false, newViewportFrameCopy); + const size_t newGrandChildPairsSize = newGrandChildPairs.size(); + calculateShadowViewMutations( innerScope, *(newGrandChildPairsSize != 0u @@ -1166,7 +1303,9 @@ static void calculateShadowViewMutations( : &mutationContainer.destructiveDownwardMutations), oldChildPair.shadowView.tag, std::move(oldGrandChildPairs), - std::move(newGrandChildPairs)); + std::move(newGrandChildPairs), + oldViewportFrameCopy, + newViewportFrameCopy); } } @@ -1194,6 +1333,19 @@ static void calculateShadowViewMutations( parentTag, oldChildPair.shadowView, static_cast(oldChildPair.mountIndex))); + auto oldViewportFrameCopy = oldViewportFrame; + if (auto scrollViewShadowNode = dynamic_cast( + oldChildPair.shadowNode)) { + oldViewportFrameCopy.origin = {}; + oldViewportFrameCopy.origin -= + scrollViewShadowNode->getContentOriginOffset( + /* includeTransform */ true); + oldViewportFrameCopy.size = + scrollViewShadowNode->getLayoutMetrics().frame.size; + } else { + oldViewportFrameCopy.origin -= + oldChildPair.shadowView.layoutMetrics.frame.origin; + } // We also have to call the algorithm recursively to clean up the entire // subtree starting from the removed view. @@ -1203,8 +1355,10 @@ static void calculateShadowViewMutations( mutationContainer.destructiveDownwardMutations, oldChildPair.shadowView.tag, sliceChildShadowNodeViewPairsFromViewNodePair( - oldChildPair, innerScope), - {}); + oldChildPair, innerScope, false, oldViewportFrameCopy), + {}, + oldViewportFrameCopy, + newViewportFrame); } } else if (index == oldChildPairs.size()) { // If we don't have any more existing children we can choose a fast path @@ -1228,6 +1382,19 @@ static void calculateShadowViewMutations( static_cast(newChildPair.mountIndex))); mutationContainer.createMutations.push_back( ShadowViewMutation::CreateMutation(newChildPair.shadowView)); + auto newViewportFrameCopy = newViewportFrame; + if (auto scrollViewShadowNode = dynamic_cast( + newChildPair.shadowNode)) { + newViewportFrameCopy.origin = {}; + newViewportFrameCopy.origin -= + scrollViewShadowNode->getContentOriginOffset( + /* includeTransform */ true); + newViewportFrameCopy.size = + scrollViewShadowNode->getLayoutMetrics().frame.size; + } else { + newViewportFrameCopy.origin -= + newChildPair.shadowView.layoutMetrics.frame.origin; + } ViewNodePairScope innerScope{}; calculateShadowViewMutations( @@ -1236,7 +1403,9 @@ static void calculateShadowViewMutations( newChildPair.shadowView.tag, {}, sliceChildShadowNodeViewPairsFromViewNodePair( - newChildPair, innerScope)); + newChildPair, innerScope, false, newViewportFrameCopy), + oldViewportFrame, + newViewportFrameCopy); } } else { // Collect map of tags in the new list @@ -1290,7 +1459,9 @@ static void calculateShadowViewMutations( oldChildPairs, parentTag, oldChildPair, - newChildPair); + newChildPair, + oldViewportFrame, + newViewportFrame); newIndex++; oldIndex++; @@ -1334,7 +1505,9 @@ static void calculateShadowViewMutations( oldChildPairs, parentTag, oldChildPair, - newChildPair); + newChildPair, + oldViewportFrame, + newViewportFrame); newInsertedPairs.erase(insertedIt); oldIndex++; @@ -1456,17 +1629,35 @@ static void calculateShadowViewMutations( if (!oldChildPair.inOtherTree() && oldChildPair.isConcreteView) { mutationContainer.deleteMutations.push_back( ShadowViewMutation::DeleteMutation(oldChildPair.shadowView)); + auto oldViewportFrameCopy = oldViewportFrame; + if (auto scrollViewShadowNode = + dynamic_cast( + oldChildPair.shadowNode)) { + oldViewportFrameCopy.origin = {}; + oldViewportFrameCopy.origin -= + scrollViewShadowNode->getContentOriginOffset( + /* includeTransform */ true); + oldViewportFrameCopy.size = + scrollViewShadowNode->getLayoutMetrics().frame.size; + } else { + oldViewportFrameCopy.origin -= + oldChildPair.shadowView.layoutMetrics.frame.origin; + } // We also have to call the algorithm recursively to clean up the // entire subtree starting from the removed view. ViewNodePairScope innerScope{}; + + auto newGrandChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( + oldChildPair, innerScope, false, oldViewportFrameCopy); calculateShadowViewMutations( innerScope, mutationContainer.destructiveDownwardMutations, oldChildPair.shadowView.tag, - sliceChildShadowNodeViewPairsFromViewNodePair( - oldChildPair, innerScope), - {}); + std::move(newGrandChildPairs), + {}, + oldViewportFrameCopy, + newViewportFrame); } } @@ -1501,14 +1692,31 @@ static void calculateShadowViewMutations( mutationContainer.createMutations.push_back( ShadowViewMutation::CreateMutation(newChildPair.shadowView)); + auto newViewportFrameCopy = newViewportFrame; + if (auto scrollViewShadowNode = dynamic_cast( + newChildPair.shadowNode)) { + newViewportFrameCopy.origin = {}; + newViewportFrameCopy.origin -= + scrollViewShadowNode->getContentOriginOffset( + /* includeTransform */ true); + newViewportFrameCopy.size = + scrollViewShadowNode->getLayoutMetrics().frame.size; + } else { + newViewportFrameCopy.origin -= + newChildPair.shadowView.layoutMetrics.frame.origin; + } + ViewNodePairScope innerScope{}; + calculateShadowViewMutations( innerScope, mutationContainer.downwardMutations, newChildPair.shadowView.tag, {}, sliceChildShadowNodeViewPairsFromViewNodePair( - newChildPair, innerScope)); + newChildPair, innerScope, false, newViewportFrameCopy), + oldViewportFrame, + newViewportFrameCopy); } } @@ -1567,16 +1775,22 @@ ShadowViewMutation::List calculateShadowViewMutations( oldRootShadowView, newRootShadowView, {})); } + auto sliceOne = sliceChildShadowNodeViewPairs( + ShadowViewNodePair{.shadowNode = &oldRootShadowNode}, + viewNodePairScope, + false, + {}); + auto sliceTwo = sliceChildShadowNodeViewPairs( + ShadowViewNodePair{.shadowNode = &newRootShadowNode}, + viewNodePairScope, + false, + {}); calculateShadowViewMutations( innerViewNodePairScope, mutations, oldRootShadowNode.getTag(), - sliceChildShadowNodeViewPairs( - ShadowViewNodePair{.shadowNode = &oldRootShadowNode}, - viewNodePairScope), - sliceChildShadowNodeViewPairs( - ShadowViewNodePair{.shadowNode = &newRootShadowNode}, - viewNodePairScope)); + std::move(sliceOne), + std::move(sliceTwo)); DEBUG_LOGS({ LOG(ERROR) << "Differ Completed: " << mutations.size() << " mutations"; diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.h b/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.h index cf8dcf8cc0d975..14883f4efd8759 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.h +++ b/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.h @@ -95,7 +95,8 @@ ShadowViewMutation::List calculateShadowViewMutations( std::vector sliceChildShadowNodeViewPairs( const ShadowViewNodePair& shadowNodePair, ViewNodePairScope& viewNodePairScope, - bool allowFlattened = false, - Point layoutOffset = {0, 0}); + bool allowFlattened, + Point layoutOffset, + Rect viewportFrame = {}); } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/stubs/stubs.cpp b/packages/react-native/ReactCommon/react/renderer/mounting/stubs/stubs.cpp index 9c3cc7e6882cfd..7a79245e2f820a 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/stubs/stubs.cpp +++ b/packages/react-native/ReactCommon/react/renderer/mounting/stubs/stubs.cpp @@ -60,7 +60,7 @@ static void calculateShadowViewMutationsForNewTree( static_cast(newChildPair->mountIndex))); auto newGrandChildPairs = - sliceChildShadowNodeViewPairs(*newChildPair, scope); + sliceChildShadowNodeViewPairs(*newChildPair, scope, false, {}, {}); calculateShadowViewMutationsForNewTree( mutations, scope, newChildPair->shadowView, newGrandChildPairs); @@ -78,7 +78,7 @@ StubViewTree buildStubViewTreeWithoutUsingDifferentiator( mutations, scope, ShadowView(rootShadowNode), - sliceChildShadowNodeViewPairs(rootShadowNodePair, scope)); + sliceChildShadowNodeViewPairs(rootShadowNodePair, scope, false, {}, {})); auto emptyRootShadowNode = rootShadowNode.clone(ShadowNodeFragment{ ShadowNodeFragment::propsPlaceholder(),