Skip to content

Commit eeb2f40

Browse files
hryhoriiK97claude
andcommitted
Track clipping views per ScrollView instead of globally
Replace the global count of views with `removeClippedSubviews` with a weak set on each ScrollView. A view with the prop on registers with its nearest ScrollView while it is in a window, and unregisters when it leaves the window, turns the prop off or is recycled. A ScrollView skips its clipping pass while it is in a window and has no such views; outside a window it keeps walking, since its content can be mounted before it is shown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 18d2949 commit eeb2f40

3 files changed

Lines changed: 213 additions & 62 deletions

File tree

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

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

9393
@end
9494

95-
// Defined in RCTViewComponentView.mm.
96-
NSUInteger RCTViewComponentViewClippingViewCount(void);
97-
9895
@implementation RCTScrollViewComponentView {
9996
ScrollViewShadowNode::ConcreteState::Shared _state;
10097
CGSize _contentSize;
@@ -109,6 +106,9 @@ @implementation RCTScrollViewComponentView {
109106
BOOL _automaticallyAdjustKeyboardInsets;
110107

111108
CGPoint _contentOffsetWhenClipped;
109+
// Views with `removeClippedSubviews` on in a window whose nearest ScrollView is this one.
110+
// See `_remountChildren`.
111+
NSHashTable<UIView *> *_clippingViews;
112112

113113
__weak UIView *_contentView;
114114

@@ -1010,15 +1010,32 @@ - (void)_remountChildren
10101010
return;
10111011
}
10121012

1013-
// The walk below only changes something at a view with `removeClippedSubviews` on.
1014-
if (RCTViewComponentViewClippingViewCount() == 0) {
1013+
// The walk below only changes something at a view with `removeClippedSubviews` on, and such
1014+
// views register with their nearest ScrollView once they are in a window. Outside a window,
1015+
// walk anyway: the content may be mounted before the ScrollView is shown.
1016+
if (self.window != nil && _clippingViews.count == 0) {
10151017
return;
10161018
}
10171019

10181020
[_scrollView updateClippedSubviewsWithClipRect:CGRectInset(_scrollView.bounds, -kClippingLeeway, -kClippingLeeway)
10191021
relativeToView:_scrollView];
10201022
}
10211023

1024+
// Called by `RCTViewComponentView` (see `_updateClippingScrollView`) for views with
1025+
// `removeClippedSubviews` on.
1026+
- (void)_registerClippingView:(UIView *)view
1027+
{
1028+
if (_clippingViews == nil) {
1029+
_clippingViews = [NSHashTable weakObjectsHashTable];
1030+
}
1031+
[_clippingViews addObject:view];
1032+
}
1033+
1034+
- (void)_unregisterClippingView:(UIView *)view
1035+
{
1036+
[_clippingViews removeObject:view];
1037+
}
1038+
10221039
#pragma mark - RCTScrollableProtocol
10231040

10241041
- (CGSize)contentSize

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

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
#import <react/renderer/components/view/accessibilityPropsConversions.h>
3434
#import <react/renderer/graphics/BlendMode.h>
3535

36+
#import <React/RCTScrollViewComponentView.h>
37+
3638
#ifdef RCT_DYNAMIC_FRAMEWORKS
3739
#import <React/RCTComponentViewFactory.h>
3840
#endif
@@ -105,24 +107,11 @@ static BOOL RCTViewIsInteractiveAccessibilityElement(UIView *view, const ViewPro
105107
}
106108
#endif
107109

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-
}
110+
// Implemented in RCTScrollViewComponentView.mm.
111+
@interface RCTScrollViewComponentView (ClippingViews)
112+
- (void)_registerClippingView:(UIView *)view;
113+
- (void)_unregisterClippingView:(UIView *)view;
114+
@end
126115

127116
@implementation RCTViewComponentView {
128117
UIColor *_backgroundColor;
@@ -136,6 +125,7 @@ @implementation RCTViewComponentView {
136125
BOOL _isJSResponder;
137126
BOOL _removeClippedSubviews;
138127
NSMutableArray<UIView *> *_reactSubviews;
128+
__weak RCTScrollViewComponentView *_clippingScrollView;
139129
NSSet<NSString *> *_Nullable _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN;
140130
UIView *_containerView;
141131
BOOL _useCustomContainerView;
@@ -385,9 +375,9 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
385375
// Compare against the current state, not `_props`: `prepareForRecycle` resets
386376
// `_removeClippedSubviews` but keeps `_props`.
387377
if (_removeClippedSubviews != newViewProps.removeClippedSubviews) {
388-
RCTUpdateClippingViewCount(_removeClippedSubviews, newViewProps.removeClippedSubviews);
389378
_removeClippedSubviews = newViewProps.removeClippedSubviews;
390379
[self _updateRemoveClippedSubviewsState];
380+
[self _updateClippingScrollView];
391381
}
392382
}
393383

@@ -836,6 +826,26 @@ - (void)didMoveToWindow
836826
if ([self _observesSafeAreaInsets]) {
837827
[self setNeedsLayout];
838828
}
829+
[self _updateClippingScrollView];
830+
}
831+
832+
// A ScrollView walks its content for views with `removeClippedSubviews` on (see
833+
// `-[RCTScrollViewComponentView _remountChildren]`). Register this view with its nearest
834+
// ScrollView while it has the prop on and is in a window, so that the ScrollView can skip
835+
// the walk when it has no such views. A ScrollView doesn't register: the walk of an outer
836+
// ScrollView stops at it, and it clips its own content.
837+
- (void)_updateClippingScrollView
838+
{
839+
BOOL needsClippingPass =
840+
_removeClippedSubviews && self.window != nil && ![self isKindOfClass:[RCTScrollViewComponentView class]];
841+
RCTScrollViewComponentView *scrollView =
842+
needsClippingPass ? [RCTScrollViewComponentView findScrollViewComponentViewForView:self] : nil;
843+
if (scrollView == _clippingScrollView) {
844+
return;
845+
}
846+
[_clippingScrollView _unregisterClippingView:self];
847+
[scrollView _registerClippingView:self];
848+
_clippingScrollView = scrollView;
839849
}
840850

841851
- (void)layoutSubviews
@@ -870,11 +880,6 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
870880
[self invalidateLayer];
871881
}
872882

873-
- (void)dealloc
874-
{
875-
RCTUpdateClippingViewCount(_removeClippedSubviews, NO);
876-
}
877-
878883
- (void)prepareForRecycle
879884
{
880885
[super prepareForRecycle];
@@ -912,8 +917,8 @@ - (void)prepareForRecycle
912917
_propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN = nil;
913918
_eventEmitter.reset();
914919
_isJSResponder = NO;
915-
RCTUpdateClippingViewCount(_removeClippedSubviews, NO);
916920
_removeClippedSubviews = NO;
921+
[self _updateClippingScrollView];
917922
_reactSubviews = [NSMutableArray new];
918923
_layoutMetrics = EmptyLayoutMetrics;
919924
}

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

Lines changed: 161 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
#import <React/RCTScrollViewComponentView.h>
89
#import <React/RCTViewComponentView.h>
910
#import <XCTest/XCTest.h>
11+
#import <react/renderer/components/scrollview/ScrollViewShadowNode.h>
1012
#import <react/renderer/components/view/ViewProps.h>
1113
#import <react/renderer/components/view/ViewShadowNode.h>
1214
#import <react/renderer/graphics/Color.h>
@@ -20,8 +22,31 @@
2022
return props;
2123
}
2224

23-
// Defined in RCTViewComponentView.mm.
24-
NSUInteger RCTViewComponentViewClippingViewCount(void);
25+
// The ScrollView's clipping pass, which runs after each mounting transaction and while scrolling.
26+
@interface RCTScrollViewComponentView (Testing)
27+
- (void)_remountChildren;
28+
@end
29+
30+
// Counts the clipping passes that reach it.
31+
@interface RCTClippingPassSpyView : UIView
32+
@property (nonatomic, assign) NSUInteger passCount;
33+
@end
34+
35+
@implementation RCTClippingPassSpyView
36+
- (void)updateClippedSubviewsWithClipRect:(CGRect)clipRect relativeToView:(UIView *)clipView
37+
{
38+
self.passCount++;
39+
}
40+
@end
41+
42+
// A ScrollView, 400 points tall, with `content` as its content view.
43+
static RCTScrollViewComponentView *makeScrollView(UIView *content)
44+
{
45+
RCTScrollViewComponentView *scrollView =
46+
[[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 400)];
47+
[scrollView mountChildComponentView:(id)content index:0];
48+
return scrollView;
49+
}
2550

2651
@interface RCTViewComponentViewTests : XCTestCase
2752
@end
@@ -166,46 +191,150 @@ - (void)testRemoveClippedSubviewsIsKeptOnRecycledView
166191
XCTAssertEqual(reactSubviews.count, 1u);
167192
}
168193

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.
194+
#pragma mark - ScrollView clipping pass
172195

173-
- (void)testClippingViewCountFollowsRemoveClippedSubviews
196+
- (void)testScrollViewSkipsClippingPassWithoutClippingViews
174197
{
175-
NSUInteger initialCount = RCTViewComponentViewClippingViewCount();
176-
RCTViewComponentView *first = [RCTViewComponentView new];
177-
RCTViewComponentView *second = [RCTViewComponentView new];
198+
UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 400)];
199+
RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new];
200+
RCTScrollViewComponentView *scrollView = makeScrollView(spy);
201+
[window addSubview:scrollView];
178202

179-
[first updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()];
180-
[second updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()];
181-
XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 2);
203+
[scrollView _remountChildren];
204+
XCTAssertEqual(spy.passCount, 0u);
205+
}
182206

183-
// Props that keep `removeClippedSubviews` on don't count the view again.
184-
[first updateProps:makeViewProps(true) oldProps:nullptr];
185-
XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 2);
207+
- (void)testScrollViewOutsideWindowRunsClippingPass
208+
{
209+
// Content can be mounted before its ScrollView is in a window, so the pass runs there as before.
210+
RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new];
211+
RCTScrollViewComponentView *scrollView = makeScrollView(spy);
186212

187-
[first updateProps:makeViewProps(false) oldProps:nullptr];
188-
XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 1);
189-
[second updateProps:makeViewProps(false) oldProps:nullptr];
190-
XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount);
213+
[scrollView _remountChildren];
214+
XCTAssertEqual(spy.passCount, 1u);
215+
}
216+
217+
- (void)testClippingViewInWindowGetsClippingPass
218+
{
219+
UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 400)];
220+
RCTViewComponentView *content = [RCTViewComponentView new];
221+
content.frame = CGRectMake(0, 0, 100, 2000);
222+
RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new];
223+
[content mountChildComponentView:(id)spy index:0];
224+
225+
// Mount the rows before turning clipping on, so that both start attached.
226+
RCTViewComponentView *list = [RCTViewComponentView new];
227+
list.frame = CGRectMake(0, 0, 100, 2000);
228+
UIView *visibleRow = [UIView new];
229+
visibleRow.frame = CGRectMake(0, 0, 100, 100);
230+
UIView *offscreenRow = [UIView new];
231+
offscreenRow.frame = CGRectMake(0, 1500, 100, 100);
232+
[list mountChildComponentView:(id)visibleRow index:0];
233+
[list mountChildComponentView:(id)offscreenRow index:1];
234+
[list updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()];
235+
[content mountChildComponentView:list index:1];
236+
XCTAssertEqual(offscreenRow.superview, list);
237+
238+
RCTScrollViewComponentView *scrollView = makeScrollView(content);
239+
[window addSubview:scrollView];
240+
241+
[scrollView _remountChildren];
242+
XCTAssertEqual(spy.passCount, 1u);
243+
XCTAssertEqual(visibleRow.superview, list);
244+
XCTAssertNil(offscreenRow.superview);
191245
}
192246

193-
- (void)testClippingViewCountAfterRecycleAndDealloc
247+
- (void)testClippingPassFollowsClippingViewProps
194248
{
195-
NSUInteger initialCount = RCTViewComponentViewClippingViewCount();
196-
@autoreleasepool {
197-
RCTViewComponentView *view = [RCTViewComponentView new];
198-
[view updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()];
199-
XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 1);
249+
UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 400)];
250+
RCTViewComponentView *content = [RCTViewComponentView new];
251+
RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new];
252+
[content mountChildComponentView:(id)spy index:0];
253+
RCTViewComponentView *list = [RCTViewComponentView new];
254+
[list updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()];
255+
[content mountChildComponentView:list index:1];
256+
RCTScrollViewComponentView *scrollView = makeScrollView(content);
257+
[window addSubview:scrollView];
258+
259+
[scrollView _remountChildren];
260+
XCTAssertEqual(spy.passCount, 1u);
261+
262+
[list updateProps:makeViewProps(false) oldProps:nullptr];
263+
[scrollView _remountChildren];
264+
XCTAssertEqual(spy.passCount, 1u);
265+
266+
[list updateProps:makeViewProps(true) oldProps:nullptr];
267+
[scrollView _remountChildren];
268+
XCTAssertEqual(spy.passCount, 2u);
269+
270+
// A clipping view outside the window doesn't need the pass.
271+
[content unmountChildComponentView:list index:1];
272+
[scrollView _remountChildren];
273+
XCTAssertEqual(spy.passCount, 2u);
274+
275+
[content mountChildComponentView:list index:1];
276+
[scrollView _remountChildren];
277+
XCTAssertEqual(spy.passCount, 3u);
278+
279+
// A recycled view starts without `removeClippedSubviews`.
280+
[content unmountChildComponentView:list index:1];
281+
[list prepareForRecycle];
282+
[content mountChildComponentView:list index:1];
283+
[scrollView _remountChildren];
284+
XCTAssertEqual(spy.passCount, 3u);
285+
286+
// Turned on outside the window, the view registers once it is attached.
287+
[content unmountChildComponentView:list index:1];
288+
[list updateProps:makeViewProps(true) oldProps:nullptr];
289+
[content mountChildComponentView:list index:1];
290+
[scrollView _remountChildren];
291+
XCTAssertEqual(spy.passCount, 4u);
292+
}
200293

201-
[view prepareForRecycle];
202-
XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount);
294+
- (void)testClippingViewOnlyRegistersWithNearestScrollView
295+
{
296+
UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 400)];
297+
RCTViewComponentView *innerContent = [RCTViewComponentView new];
298+
RCTClippingPassSpyView *innerSpy = [RCTClippingPassSpyView new];
299+
[innerContent mountChildComponentView:(id)innerSpy index:0];
300+
RCTViewComponentView *list = [RCTViewComponentView new];
301+
[list updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()];
302+
[innerContent mountChildComponentView:list index:1];
303+
RCTScrollViewComponentView *innerScrollView = makeScrollView(innerContent);
304+
305+
RCTViewComponentView *content = [RCTViewComponentView new];
306+
RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new];
307+
[content mountChildComponentView:(id)spy index:0];
308+
[content mountChildComponentView:innerScrollView index:1];
309+
RCTScrollViewComponentView *outerScrollView = makeScrollView(content);
310+
[window addSubview:outerScrollView];
311+
312+
// The outer pass stops at the inner ScrollView, which handles `list` itself.
313+
[outerScrollView _remountChildren];
314+
XCTAssertEqual(spy.passCount, 0u);
315+
[innerScrollView _remountChildren];
316+
XCTAssertEqual(innerSpy.passCount, 1u);
317+
}
203318

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);
319+
- (void)testScrollViewWithRemoveClippedSubviewsDoesNotRegisterWithOuterScrollView
320+
{
321+
// For example a horizontal list inside a vertical one: ScrollView forwards the prop to the
322+
// native ScrollView too, but the inner ScrollView clips its own content.
323+
UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 400)];
324+
RCTScrollViewComponentView *innerScrollView = makeScrollView([RCTViewComponentView new]);
325+
auto innerProps = std::make_shared<ScrollViewProps>();
326+
innerProps->removeClippedSubviews = true;
327+
[innerScrollView updateProps:innerProps oldProps:ScrollViewShadowNode::defaultSharedProps()];
328+
329+
RCTViewComponentView *content = [RCTViewComponentView new];
330+
RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new];
331+
[content mountChildComponentView:(id)spy index:0];
332+
[content mountChildComponentView:innerScrollView index:1];
333+
RCTScrollViewComponentView *outerScrollView = makeScrollView(content);
334+
[window addSubview:outerScrollView];
335+
336+
[outerScrollView _remountChildren];
337+
XCTAssertEqual(spy.passCount, 0u);
209338
}
210339

211340
#pragma mark - hitTest against non-invertible transforms (#50797)

0 commit comments

Comments
 (0)