Skip to content

Commit ff78e95

Browse files
mozziusmeta-codesync[bot]
authored andcommitted
Fix iOS removeClippedSubviews being lost on recycled views (#58915)
Summary: On iOS, `prepareForRecycle` resets `_removeClippedSubviews` to `NO` but keeps `_props`, and `updateProps:oldProps:` only set the flag when `removeClippedSubviews` differed from `_props`. So a recycled view whose previous props also had `removeClippedSubviews` never turned clipping back on, and a remounted `ScrollView`/`FlatList` kept every row attached. This compares against `_removeClippedSubviews` itself, and adds a test. Fixes #58914. Repro: https://github.com/mozzius/view-remove-clipped-subviews-recycle-repro ## Changelog: [IOS] [FIXED] - removeClippedSubviews is kept on recycled views Pull Request resolved: #58915 Test Plan: In the repro (a `ScrollView` with `removeClippedSubviews` and 100 rows, unmounted and mounted again), the recycled content view keeps all 100/100 rows attached on every remount without this change (7/7, including 3/3 on unmodified 0.87.1), and 7/100 with it (3/3), the same as on first mount. The new `RCTViewComponentViewTests` case, run inside the repro app against 0.87.1 built from source, fails without the change and passes with it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed By: sammy-SC Differential Revision: D123878627 Pulled By: cipolleschi fbshipit-source-id: 21d792698d74805f61a9a93f5b944d8f5abcd7f3
1 parent 268b302 commit ff78e95

2 files changed

Lines changed: 23 additions & 1 deletion

File tree

‎packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,9 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
359359

360360
// Disable `removeClippedSubviews` when Fabric View Culling is enabled.
361361
if (!ReactNativeFeatureFlags::enableViewCulling()) {
362-
if (oldViewProps.removeClippedSubviews != newViewProps.removeClippedSubviews) {
362+
// Compare against the current state, not `_props`: `prepareForRecycle` resets
363+
// `_removeClippedSubviews` but keeps `_props`.
364+
if (_removeClippedSubviews != newViewProps.removeClippedSubviews) {
363365
_removeClippedSubviews = newViewProps.removeClippedSubviews;
364366
[self _updateRemoveClippedSubviewsState];
365367
}

‎packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,26 @@ - (void)testUnmountAfterToggleOffCleansUpReactSubviews
143143
XCTAssertNil(child2.superview);
144144
}
145145

146+
- (void)testRemoveClippedSubviewsIsKeptOnRecycledView
147+
{
148+
RCTViewComponentView *view = [RCTViewComponentView new];
149+
[view updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()];
150+
[view prepareForRecycle];
151+
152+
// Reused for another view with removeClippedSubviews. Like the mounting layer
153+
// does for an Insert, pass no old props: the view diffs against its own.
154+
[view updateProps:makeViewProps(true) oldProps:nullptr];
155+
156+
UIView *child = [UIView new];
157+
child.frame = CGRectMake(0, 400, 50, 50);
158+
[view mountChildComponentView:(id)child index:0];
159+
160+
// Clipping is on, so the child is tracked and left for the clipping pass to attach.
161+
XCTAssertNil(child.superview);
162+
NSMutableArray *reactSubviews = [view valueForKey:@"_reactSubviews"];
163+
XCTAssertEqual(reactSubviews.count, 1u);
164+
}
165+
146166
#pragma mark - hitTest against non-invertible transforms (#50797)
147167

148168
- (void)testHitTestReturnsNilForZeroScaleYView

0 commit comments

Comments
 (0)