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
This commit is contained in:
Samuel Susla
2025-05-28 06:25:41 -07:00
committed by Facebook GitHub Bot
parent 618ed98882
commit 7dda5512ff
6 changed files with 141 additions and 3 deletions
@@ -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<HostInstance>();
Fantom.runTask(() => {
root.render(
<ScrollView style={{height: 100, width: 100}}>
<View nativeID="modal parent" style={{marginTop: 101}}>
<Modal ref={nodeRef}>
<View nativeID="child" style={{height: 10, width: 10}} />
</Modal>
</View>
</ScrollView>,
);
});
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(
<ScrollView style={{height: 100, width: 100}}>
<View nativeID="modal parent" style={{marginTop: 101}} />
</ScrollView>,
);
});
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(
<ScrollView style={{height: 100, width: 100}}>
<View style={{marginTop: 101}} />
</ScrollView>,
);
});
const nodeRef = createRef<HostInstance>();
// Adding modal to view hierarchy.
Fantom.runTask(() => {
root.render(
<ScrollView style={{height: 100, width: 100}}>
<View style={{marginTop: 101}}>
<Modal ref={nodeRef}>
<View nativeID="child" style={{height: 10, width: 10}} />
</Modal>
</View>
</ScrollView>,
);
});
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"}',
);
});
});
@@ -30,6 +30,9 @@ class ModalHostViewShadowNode final : public ConcreteViewShadowNode<
static ShadowNodeTraits BaseTraits() {
auto traits = ConcreteViewShadowNode::BaseTraits();
traits.set(ShadowNodeTraits::Trait::RootNodeKind);
// <Modal> 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;
}
};
@@ -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<ShadowNode::ListOfShared>(*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());
@@ -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.
*/
@@ -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,
};
/*
@@ -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;