Skip to content

Commit 18d2949

Browse files
hryhoriiK97claude
andcommitted
Skip the iOS ScrollView clip walk when no view clips its subviews
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a613baf commit 18d2949

3 files changed

Lines changed: 79 additions & 0 deletions

File tree

‎packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ @interface RCTScrollViewComponentView () <
9292

9393
@end
9494

95+
// Defined in RCTViewComponentView.mm.
96+
NSUInteger RCTViewComponentViewClippingViewCount(void);
97+
9598
@implementation RCTScrollViewComponentView {
9699
ScrollViewShadowNode::ConcreteState::Shared _state;
97100
CGSize _contentSize;
@@ -1007,6 +1010,11 @@ - (void)_remountChildren
10071010
return;
10081011
}
10091012

1013+
// The walk below only changes something at a view with `removeClippedSubviews` on.
1014+
if (RCTViewComponentViewClippingViewCount() == 0) {
1015+
return;
1016+
}
1017+
10101018
[_scrollView updateClippedSubviewsWithClipRect:CGRectInset(_scrollView.bounds, -kClippingLeeway, -kClippingLeeway)
10111019
relativeToView:_scrollView];
10121020
}

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,25 @@ static BOOL RCTViewIsInteractiveAccessibilityElement(UIView *view, const ViewPro
105105
}
106106
#endif
107107

108+
// The number of live views with `removeClippedSubviews` on. Only such views act on
109+
// `updateClippedSubviewsWithClipRect:relativeToView:`, so while it is 0 a ScrollView
110+
// can skip walking its subviews. Views are only created and changed on the main thread.
111+
static NSUInteger RCTClippingViewCount = 0;
112+
113+
NSUInteger RCTViewComponentViewClippingViewCount(void)
114+
{
115+
return RCTClippingViewCount;
116+
}
117+
118+
static void RCTUpdateClippingViewCount(BOOL wasClipping, BOOL isClipping)
119+
{
120+
if (isClipping && !wasClipping) {
121+
RCTClippingViewCount++;
122+
} else if (wasClipping && !isClipping) {
123+
RCTClippingViewCount--;
124+
}
125+
}
126+
108127
@implementation RCTViewComponentView {
109128
UIColor *_backgroundColor;
110129
CALayer *_backgroundColorLayer;
@@ -366,6 +385,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
366385
// Compare against the current state, not `_props`: `prepareForRecycle` resets
367386
// `_removeClippedSubviews` but keeps `_props`.
368387
if (_removeClippedSubviews != newViewProps.removeClippedSubviews) {
388+
RCTUpdateClippingViewCount(_removeClippedSubviews, newViewProps.removeClippedSubviews);
369389
_removeClippedSubviews = newViewProps.removeClippedSubviews;
370390
[self _updateRemoveClippedSubviewsState];
371391
}
@@ -850,6 +870,11 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
850870
[self invalidateLayer];
851871
}
852872

873+
- (void)dealloc
874+
{
875+
RCTUpdateClippingViewCount(_removeClippedSubviews, NO);
876+
}
877+
853878
- (void)prepareForRecycle
854879
{
855880
[super prepareForRecycle];
@@ -887,6 +912,7 @@ - (void)prepareForRecycle
887912
_propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN = nil;
888913
_eventEmitter.reset();
889914
_isJSResponder = NO;
915+
RCTUpdateClippingViewCount(_removeClippedSubviews, NO);
890916
_removeClippedSubviews = NO;
891917
_reactSubviews = [NSMutableArray new];
892918
_layoutMetrics = EmptyLayoutMetrics;

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
return props;
2121
}
2222

23+
// Defined in RCTViewComponentView.mm.
24+
NSUInteger RCTViewComponentViewClippingViewCount(void);
25+
2326
@interface RCTViewComponentViewTests : XCTestCase
2427
@end
2528

@@ -163,6 +166,48 @@ - (void)testRemoveClippedSubviewsIsKeptOnRecycledView
163166
XCTAssertEqual(reactSubviews.count, 1u);
164167
}
165168

169+
#pragma mark - Clipping view count
170+
171+
// The count is shared by all views, so each test compares it against its value at the start.
172+
173+
- (void)testClippingViewCountFollowsRemoveClippedSubviews
174+
{
175+
NSUInteger initialCount = RCTViewComponentViewClippingViewCount();
176+
RCTViewComponentView *first = [RCTViewComponentView new];
177+
RCTViewComponentView *second = [RCTViewComponentView new];
178+
179+
[first updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()];
180+
[second updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()];
181+
XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 2);
182+
183+
// Props that keep `removeClippedSubviews` on don't count the view again.
184+
[first updateProps:makeViewProps(true) oldProps:nullptr];
185+
XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 2);
186+
187+
[first updateProps:makeViewProps(false) oldProps:nullptr];
188+
XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 1);
189+
[second updateProps:makeViewProps(false) oldProps:nullptr];
190+
XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount);
191+
}
192+
193+
- (void)testClippingViewCountAfterRecycleAndDealloc
194+
{
195+
NSUInteger initialCount = RCTViewComponentViewClippingViewCount();
196+
@autoreleasepool {
197+
RCTViewComponentView *view = [RCTViewComponentView new];
198+
[view updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()];
199+
XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 1);
200+
201+
[view prepareForRecycle];
202+
XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount);
203+
204+
[view updateProps:makeViewProps(true) oldProps:nullptr];
205+
XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 1);
206+
}
207+
// The view was deallocated with `removeClippedSubviews` on.
208+
XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount);
209+
}
210+
166211
#pragma mark - hitTest against non-invertible transforms (#50797)
167212

168213
- (void)testHitTestReturnsNilForZeroScaleYView

0 commit comments

Comments
 (0)