Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 7dda551

Browse files
sammy-SCfacebook-github-bot
authored andcommittedMay 28, 2025
introduce opt out mechanism for View Culling (#51657)
Summary: Pull Request resolved: #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
1 parent 618ed98 commit 7dda551

File tree

6 files changed

+141
-3
lines changed

6 files changed

+141
-3
lines changed
 

‎packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-viewCulling-itest.js

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -784,18 +784,22 @@ test('culling inside of Modal', () => {
784784
height: 100,
785785
});
786786
});
787+
787788
Fantom.runWorkLoop();
788789

789790
expect(root.takeMountingManagerLogs()).toEqual([
790791
'Update {type: "RootView", nativeID: (root)}',
791792
'Create {type: "ScrollView", nativeID: (N/A)}',
792-
'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}',
793793
'Create {type: "View", nativeID: (N/A)}',
794794
'Create {type: "ModalHostView", nativeID: (root)}',
795795
'Create {type: "View", nativeID: (N/A)}',
796796
'Insert {type: "View", parentNativeID: (root), index: 0, nativeID: (N/A)}',
797797
'Insert {type: "ModalHostView", parentNativeID: (N/A), index: 0, nativeID: (root)}',
798798
'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: (N/A)}',
799+
'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}',
800+
'Update {type: "View", nativeID: (N/A)}',
801+
'Update {type: "ModalHostView", nativeID: (root)}',
802+
'Update {type: "View", nativeID: (N/A)}',
799803
]);
800804

801805
Fantom.runTask(() => {
@@ -2324,3 +2328,89 @@ describe('reparenting', () => {
23242328
]);
23252329
});
23262330
});
2331+
2332+
describe('opt out mechanism - Unstable_uncullableView & Unstable_uncullableTrace', () => {
2333+
test('modal is still rendered even though it is in culling region', () => {
2334+
const root = Fantom.createRoot({viewportWidth: 100, viewportHeight: 100});
2335+
const nodeRef = createRef<HostInstance>();
2336+
2337+
Fantom.runTask(() => {
2338+
root.render(
2339+
<ScrollView style={{height: 100, width: 100}}>
2340+
<View nativeID="modal parent" style={{marginTop: 101}}>
2341+
<Modal ref={nodeRef}>
2342+
<View nativeID="child" style={{height: 10, width: 10}} />
2343+
</Modal>
2344+
</View>
2345+
</ScrollView>,
2346+
);
2347+
});
2348+
const element = ensureInstance(nodeRef.current, ReactNativeElement);
2349+
2350+
Fantom.runOnUIThread(() => {
2351+
Fantom.enqueueModalSizeUpdate(element, {
2352+
width: 100,
2353+
height: 100,
2354+
});
2355+
});
2356+
Fantom.runWorkLoop();
2357+
2358+
const logs = root.takeMountingManagerLogs();
2359+
expect(logs).toContain('Create {type: "View", nativeID: "child"}');
2360+
expect(logs).toContain('Create {type: "View", nativeID: "modal parent"}');
2361+
2362+
// Modal is unmounted. Views that were only mounted because of its existence must be unmounted.
2363+
Fantom.runTask(() => {
2364+
root.render(
2365+
<ScrollView style={{height: 100, width: 100}}>
2366+
<View nativeID="modal parent" style={{marginTop: 101}} />
2367+
</ScrollView>,
2368+
);
2369+
});
2370+
2371+
expect(root.takeMountingManagerLogs()).toContain(
2372+
'Delete {type: "View", nativeID: "modal parent"}',
2373+
);
2374+
});
2375+
2376+
test('modal is mounted in second update', () => {
2377+
const root = Fantom.createRoot({viewportWidth: 100, viewportHeight: 100});
2378+
2379+
Fantom.runTask(() => {
2380+
root.render(
2381+
<ScrollView style={{height: 100, width: 100}}>
2382+
<View style={{marginTop: 101}} />
2383+
</ScrollView>,
2384+
);
2385+
});
2386+
2387+
const nodeRef = createRef<HostInstance>();
2388+
2389+
// Adding modal to view hierarchy.
2390+
Fantom.runTask(() => {
2391+
root.render(
2392+
<ScrollView style={{height: 100, width: 100}}>
2393+
<View style={{marginTop: 101}}>
2394+
<Modal ref={nodeRef}>
2395+
<View nativeID="child" style={{height: 10, width: 10}} />
2396+
</Modal>
2397+
</View>
2398+
</ScrollView>,
2399+
);
2400+
});
2401+
2402+
const element = ensureInstance(nodeRef.current, ReactNativeElement);
2403+
2404+
Fantom.runOnUIThread(() => {
2405+
Fantom.enqueueModalSizeUpdate(element, {
2406+
width: 100,
2407+
height: 100,
2408+
});
2409+
});
2410+
Fantom.runWorkLoop();
2411+
2412+
expect(root.takeMountingManagerLogs()).toContain(
2413+
'Create {type: "View", nativeID: "child"}',
2414+
);
2415+
});
2416+
});

‎packages/react-native/ReactCommon/react/renderer/components/modal/ModalHostViewShadowNode.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ class ModalHostViewShadowNode final : public ConcreteViewShadowNode<
3030
static ShadowNodeTraits BaseTraits() {
3131
auto traits = ConcreteViewShadowNode::BaseTraits();
3232
traits.set(ShadowNodeTraits::Trait::RootNodeKind);
33+
// <Modal> has a side effect of showing the modal overlay and
34+
// must not be culled. Otherwise, the modal overlay will not be shown.
35+
traits.set(ShadowNodeTraits::Trait::Unstable_uncullableView);
3336
return traits;
3437
}
3538
};

‎packages/react-native/ReactCommon/react/renderer/core/ShadowNode.cpp

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ ShadowNode::ShadowNode(
9696
child->family_->setParent(family_);
9797
}
9898

99+
updateTraitsIfNeccessary();
100+
99101
// The first node of the family gets its state committed automatically.
100102
family_->setMostRecentState(state_);
101103
}
@@ -130,6 +132,7 @@ ShadowNode::ShadowNode(
130132
for (const auto& child : *children_) {
131133
child->family_->setParent(family_);
132134
}
135+
updateTraitsIfNeccessary();
133136
}
134137
}
135138

@@ -242,6 +245,7 @@ void ShadowNode::appendChild(const ShadowNode::Shared& child) {
242245
children.push_back(child);
243246

244247
child->family_->setParent(family_);
248+
updateTraitsIfNeccessary();
245249
}
246250

247251
void ShadowNode::replaceChild(
@@ -274,6 +278,7 @@ void ShadowNode::replaceChild(
274278
}
275279

276280
react_native_assert(false && "Child to replace was not found.");
281+
updateTraitsIfNeccessary();
277282
}
278283

279284
void ShadowNode::cloneChildrenIfShared() {
@@ -285,6 +290,25 @@ void ShadowNode::cloneChildrenIfShared() {
285290
children_ = std::make_shared<ShadowNode::ListOfShared>(*children_);
286291
}
287292

293+
void ShadowNode::updateTraitsIfNeccessary() {
294+
if (ReactNativeFeatureFlags::enableViewCulling()) {
295+
if (traits_.check(ShadowNodeTraits::Trait::Unstable_uncullableView)) {
296+
return;
297+
}
298+
299+
for (const auto& child : *children_) {
300+
if (child->getTraits().check(
301+
ShadowNodeTraits::Trait::Unstable_uncullableView) ||
302+
child->getTraits().check(
303+
ShadowNodeTraits::Trait::Unstable_uncullableTrace)) {
304+
traits_.set(ShadowNodeTraits::Trait::Unstable_uncullableTrace);
305+
return;
306+
}
307+
}
308+
traits_.unset(ShadowNodeTraits::Trait::Unstable_uncullableTrace);
309+
}
310+
}
311+
288312
void ShadowNode::setMounted(bool mounted) const {
289313
if (mounted) {
290314
family_->setMostRecentState(getState());

‎packages/react-native/ReactCommon/react/renderer/core/ShadowNode.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,15 @@ class ShadowNode : public Sealable,
244244
*/
245245
void cloneChildrenIfShared();
246246

247+
/*
248+
* Updates the node's traits based on its children's traits.
249+
* Specifically, if view culling is enabled and any child has the
250+
* Unstable_uncullableView or Unstable_uncullableTrace trait, this node will
251+
* also be marked as uncullable. This ensures that if a child needs to be
252+
* rendered, its parent will be too.
253+
*/
254+
void updateTraitsIfNeccessary();
255+
247256
/*
248257
* Pointer to a family object that this shadow node belongs to.
249258
*/

‎packages/react-native/ReactCommon/react/renderer/core/ShadowNodeTraits.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ class ShadowNodeTraits {
7474

7575
// Indicates if the node is keyboard focusable.
7676
KeyboardFocusable = 1 << 11,
77+
78+
// Indicates if the node is uncullable. Apply this to your component
79+
// if it has side effects beyond just rendering (e.g. it opens a modal).
80+
Unstable_uncullableView = 1 << 12,
81+
82+
// Must not be set directly. It is used by the view culling algorithm to
83+
// efficiently determine if a node is uncullable.
84+
Unstable_uncullableTrace = 1 << 13,
7785
};
7886

7987
/*

‎packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,12 @@ static void sliceChildShadowNodeViewPairsRecursively(
6666
auto shadowView = ShadowView(childShadowNode);
6767

6868
if (ReactNativeFeatureFlags::enableViewCulling()) {
69-
if (cullingContext.shouldConsiderCulling() &&
70-
shadowView.layoutMetrics != EmptyLayoutMetrics) {
69+
auto isViewCullable =
70+
!shadowView.traits.check(
71+
ShadowNodeTraits::Trait::Unstable_uncullableView) &&
72+
!shadowView.traits.check(
73+
ShadowNodeTraits::Trait::Unstable_uncullableTrace);
74+
if (cullingContext.shouldConsiderCulling() && isViewCullable) {
7175
auto overflowInsetFrame =
7276
shadowView.layoutMetrics.getOverflowInsetFrame() *
7377
cullingContext.transform;

0 commit comments

Comments
 (0)
Please sign in to comment.