From 7dda5512ff7559fb887de94780da99dcefd06241 Mon Sep 17 00:00:00 2001 From: Samuel Susla Date: Wed, 28 May 2025 06:25:41 -0700 Subject: [PATCH] introduce opt out mechanism for View Culling (#51657) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/51657 changelog: [internal] Introduce a new ShadowNodeTraits: `Unstable_uncullableView` and `Unstable_uncullableTrace`. As the name suggests, this is not stable API yet. When a shadow node sets this trait, it will be opted out of view culling together with its ancestors all the way to the root. The trait is propagated to its parent in 4 different places: 1. When node is first created. 2. When node is cloned. 3. When child is appended. 4. When child is replaced. we can safely do it only in those places because React constructs nodes from bottom up. We are leveraging this implementation detail here but if that changes in the future, a traversal will be required. Alternative solution considered here was a traversal of shadow tree during commit phase to propagate `Unstable_uncullable` trait. This could be done in a separate traversal or as part of layout phase where layout information is copied out of Yoga tree. Leveraging the fact that React is cloning bottom up makes the implementation simpler. If React changes its cloning approach in the future, this will be caught by tests. Reviewed By: lenaic Differential Revision: D75476847 fbshipit-source-id: f1e98804565c140c64945662af0247b1bd0e1882 --- .../__tests__/ScrollView-viewCulling-itest.js | 92 ++++++++++++++++++- .../modal/ModalHostViewShadowNode.h | 3 + .../react/renderer/core/ShadowNode.cpp | 24 +++++ .../react/renderer/core/ShadowNode.h | 9 ++ .../react/renderer/core/ShadowNodeTraits.h | 8 ++ .../sliceChildShadowNodeViewPairs.cpp | 8 +- 6 files changed, 141 insertions(+), 3 deletions(-) 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 index 640dd5eee11..d7536bb4104 100644 --- a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-viewCulling-itest.js +++ b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-viewCulling-itest.js @@ -784,18 +784,22 @@ test('culling inside of Modal', () => { height: 100, }); }); + Fantom.runWorkLoop(); 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)}', 'Create {type: "View", nativeID: (N/A)}', 'Create {type: "ModalHostView", nativeID: (root)}', 'Create {type: "View", nativeID: (N/A)}', 'Insert {type: "View", parentNativeID: (root), index: 0, nativeID: (N/A)}', 'Insert {type: "ModalHostView", parentNativeID: (N/A), index: 0, nativeID: (root)}', 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}', + 'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}', + 'Update {type: "View", nativeID: (N/A)}', + 'Update {type: "ModalHostView", nativeID: (root)}', + 'Update {type: "View", nativeID: (N/A)}', ]); Fantom.runTask(() => { @@ -2324,3 +2328,89 @@ describe('reparenting', () => { ]); }); }); + +describe('opt out mechanism - Unstable_uncullableView & Unstable_uncullableTrace', () => { + test('modal is still rendered even though it is in culling region', () => { + const root = Fantom.createRoot({viewportWidth: 100, viewportHeight: 100}); + const nodeRef = createRef(); + + Fantom.runTask(() => { + root.render( + + + + + + + , + ); + }); + const element = ensureInstance(nodeRef.current, ReactNativeElement); + + Fantom.runOnUIThread(() => { + Fantom.enqueueModalSizeUpdate(element, { + width: 100, + height: 100, + }); + }); + Fantom.runWorkLoop(); + + const logs = root.takeMountingManagerLogs(); + expect(logs).toContain('Create {type: "View", nativeID: "child"}'); + expect(logs).toContain('Create {type: "View", nativeID: "modal parent"}'); + + // Modal is unmounted. Views that were only mounted because of its existence must be unmounted. + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(root.takeMountingManagerLogs()).toContain( + 'Delete {type: "View", nativeID: "modal parent"}', + ); + }); + + test('modal is mounted in second update', () => { + const root = Fantom.createRoot({viewportWidth: 100, viewportHeight: 100}); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + const nodeRef = createRef(); + + // Adding modal to view hierarchy. + Fantom.runTask(() => { + root.render( + + + + + + + , + ); + }); + + const element = ensureInstance(nodeRef.current, ReactNativeElement); + + Fantom.runOnUIThread(() => { + Fantom.enqueueModalSizeUpdate(element, { + width: 100, + height: 100, + }); + }); + Fantom.runWorkLoop(); + + expect(root.takeMountingManagerLogs()).toContain( + 'Create {type: "View", nativeID: "child"}', + ); + }); +}); diff --git a/packages/react-native/ReactCommon/react/renderer/components/modal/ModalHostViewShadowNode.h b/packages/react-native/ReactCommon/react/renderer/components/modal/ModalHostViewShadowNode.h index 7c832dc0ca5..448b836f46d 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/modal/ModalHostViewShadowNode.h +++ b/packages/react-native/ReactCommon/react/renderer/components/modal/ModalHostViewShadowNode.h @@ -30,6 +30,9 @@ class ModalHostViewShadowNode final : public ConcreteViewShadowNode< static ShadowNodeTraits BaseTraits() { auto traits = ConcreteViewShadowNode::BaseTraits(); traits.set(ShadowNodeTraits::Trait::RootNodeKind); + // has a side effect of showing the modal overlay and + // must not be culled. Otherwise, the modal overlay will not be shown. + traits.set(ShadowNodeTraits::Trait::Unstable_uncullableView); return traits; } }; diff --git a/packages/react-native/ReactCommon/react/renderer/core/ShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/core/ShadowNode.cpp index 68c66e0d7e0..2312d150457 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/ShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/core/ShadowNode.cpp @@ -96,6 +96,8 @@ ShadowNode::ShadowNode( child->family_->setParent(family_); } + updateTraitsIfNeccessary(); + // The first node of the family gets its state committed automatically. family_->setMostRecentState(state_); } @@ -130,6 +132,7 @@ ShadowNode::ShadowNode( for (const auto& child : *children_) { child->family_->setParent(family_); } + updateTraitsIfNeccessary(); } } @@ -242,6 +245,7 @@ void ShadowNode::appendChild(const ShadowNode::Shared& child) { children.push_back(child); child->family_->setParent(family_); + updateTraitsIfNeccessary(); } void ShadowNode::replaceChild( @@ -274,6 +278,7 @@ void ShadowNode::replaceChild( } react_native_assert(false && "Child to replace was not found."); + updateTraitsIfNeccessary(); } void ShadowNode::cloneChildrenIfShared() { @@ -285,6 +290,25 @@ void ShadowNode::cloneChildrenIfShared() { children_ = std::make_shared(*children_); } +void ShadowNode::updateTraitsIfNeccessary() { + if (ReactNativeFeatureFlags::enableViewCulling()) { + if (traits_.check(ShadowNodeTraits::Trait::Unstable_uncullableView)) { + return; + } + + for (const auto& child : *children_) { + if (child->getTraits().check( + ShadowNodeTraits::Trait::Unstable_uncullableView) || + child->getTraits().check( + ShadowNodeTraits::Trait::Unstable_uncullableTrace)) { + traits_.set(ShadowNodeTraits::Trait::Unstable_uncullableTrace); + return; + } + } + traits_.unset(ShadowNodeTraits::Trait::Unstable_uncullableTrace); + } +} + void ShadowNode::setMounted(bool mounted) const { if (mounted) { family_->setMostRecentState(getState()); diff --git a/packages/react-native/ReactCommon/react/renderer/core/ShadowNode.h b/packages/react-native/ReactCommon/react/renderer/core/ShadowNode.h index d3a400d0b5d..cc14de313cc 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/ShadowNode.h +++ b/packages/react-native/ReactCommon/react/renderer/core/ShadowNode.h @@ -244,6 +244,15 @@ class ShadowNode : public Sealable, */ void cloneChildrenIfShared(); + /* + * Updates the node's traits based on its children's traits. + * Specifically, if view culling is enabled and any child has the + * Unstable_uncullableView or Unstable_uncullableTrace trait, this node will + * also be marked as uncullable. This ensures that if a child needs to be + * rendered, its parent will be too. + */ + void updateTraitsIfNeccessary(); + /* * Pointer to a family object that this shadow node belongs to. */ diff --git a/packages/react-native/ReactCommon/react/renderer/core/ShadowNodeTraits.h b/packages/react-native/ReactCommon/react/renderer/core/ShadowNodeTraits.h index ff15e8b7831..ef60eba2ad1 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/ShadowNodeTraits.h +++ b/packages/react-native/ReactCommon/react/renderer/core/ShadowNodeTraits.h @@ -74,6 +74,14 @@ class ShadowNodeTraits { // Indicates if the node is keyboard focusable. KeyboardFocusable = 1 << 11, + + // Indicates if the node is uncullable. Apply this to your component + // if it has side effects beyond just rendering (e.g. it opens a modal). + Unstable_uncullableView = 1 << 12, + + // Must not be set directly. It is used by the view culling algorithm to + // efficiently determine if a node is uncullable. + Unstable_uncullableTrace = 1 << 13, }; /* diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp b/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp index 4dd4fc834f4..024a76689ae 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp +++ b/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp @@ -66,8 +66,12 @@ static void sliceChildShadowNodeViewPairsRecursively( auto shadowView = ShadowView(childShadowNode); if (ReactNativeFeatureFlags::enableViewCulling()) { - if (cullingContext.shouldConsiderCulling() && - shadowView.layoutMetrics != EmptyLayoutMetrics) { + auto isViewCullable = + !shadowView.traits.check( + ShadowNodeTraits::Trait::Unstable_uncullableView) && + !shadowView.traits.check( + ShadowNodeTraits::Trait::Unstable_uncullableTrace); + if (cullingContext.shouldConsiderCulling() && isViewCullable) { auto overflowInsetFrame = shadowView.layoutMetrics.getOverflowInsetFrame() * cullingContext.transform;