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;