From 18d29498501798405aba718deba21300c15bef59 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Wed, 21 Oct 2026 02:19:33 +0200 Subject: [PATCH 1/2] Skip the iOS ScrollView clip walk when no view clips its subviews Co-Authored-By: Claude Opus 5.5 --- .../ScrollView/RCTScrollViewComponentView.mm | 8 ++++ .../View/RCTViewComponentView.mm | 26 +++++++++++ .../Mounting/RCTViewComponentViewTests.mm | 45 +++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index d9e3b901824e..e98a1ca9652d 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -92,6 +92,9 @@ @interface RCTScrollViewComponentView () < @end +// Defined in RCTViewComponentView.mm. +NSUInteger RCTViewComponentViewClippingViewCount(void); + @implementation RCTScrollViewComponentView { ScrollViewShadowNode::ConcreteState::Shared _state; CGSize _contentSize; @@ -1007,6 +1010,11 @@ - (void)_remountChildren return; } + // The walk below only changes something at a view with `removeClippedSubviews` on. + if (RCTViewComponentViewClippingViewCount() == 0) { + return; + } + [_scrollView updateClippedSubviewsWithClipRect:CGRectInset(_scrollView.bounds, -kClippingLeeway, -kClippingLeeway) relativeToView:_scrollView]; } diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index bdd0bb5af232..f420cfaf59ee 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -105,6 +105,25 @@ static BOOL RCTViewIsInteractiveAccessibilityElement(UIView *view, const ViewPro } #endif +// The number of live views with `removeClippedSubviews` on. Only such views act on +// `updateClippedSubviewsWithClipRect:relativeToView:`, so while it is 0 a ScrollView +// can skip walking its subviews. Views are only created and changed on the main thread. +static NSUInteger RCTClippingViewCount = 0; + +NSUInteger RCTViewComponentViewClippingViewCount(void) +{ + return RCTClippingViewCount; +} + +static void RCTUpdateClippingViewCount(BOOL wasClipping, BOOL isClipping) +{ + if (isClipping && !wasClipping) { + RCTClippingViewCount++; + } else if (wasClipping && !isClipping) { + RCTClippingViewCount--; + } +} + @implementation RCTViewComponentView { UIColor *_backgroundColor; CALayer *_backgroundColorLayer; @@ -366,6 +385,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & // Compare against the current state, not `_props`: `prepareForRecycle` resets // `_removeClippedSubviews` but keeps `_props`. if (_removeClippedSubviews != newViewProps.removeClippedSubviews) { + RCTUpdateClippingViewCount(_removeClippedSubviews, newViewProps.removeClippedSubviews); _removeClippedSubviews = newViewProps.removeClippedSubviews; [self _updateRemoveClippedSubviewsState]; } @@ -850,6 +870,11 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask [self invalidateLayer]; } +- (void)dealloc +{ + RCTUpdateClippingViewCount(_removeClippedSubviews, NO); +} + - (void)prepareForRecycle { [super prepareForRecycle]; @@ -887,6 +912,7 @@ - (void)prepareForRecycle _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN = nil; _eventEmitter.reset(); _isJSResponder = NO; + RCTUpdateClippingViewCount(_removeClippedSubviews, NO); _removeClippedSubviews = NO; _reactSubviews = [NSMutableArray new]; _layoutMetrics = EmptyLayoutMetrics; diff --git a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm index 2828481a744a..eace99c6738f 100644 --- a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm +++ b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm @@ -20,6 +20,9 @@ return props; } +// Defined in RCTViewComponentView.mm. +NSUInteger RCTViewComponentViewClippingViewCount(void); + @interface RCTViewComponentViewTests : XCTestCase @end @@ -163,6 +166,48 @@ - (void)testRemoveClippedSubviewsIsKeptOnRecycledView XCTAssertEqual(reactSubviews.count, 1u); } +#pragma mark - Clipping view count + +// The count is shared by all views, so each test compares it against its value at the start. + +- (void)testClippingViewCountFollowsRemoveClippedSubviews +{ + NSUInteger initialCount = RCTViewComponentViewClippingViewCount(); + RCTViewComponentView *first = [RCTViewComponentView new]; + RCTViewComponentView *second = [RCTViewComponentView new]; + + [first updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()]; + [second updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()]; + XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 2); + + // Props that keep `removeClippedSubviews` on don't count the view again. + [first updateProps:makeViewProps(true) oldProps:nullptr]; + XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 2); + + [first updateProps:makeViewProps(false) oldProps:nullptr]; + XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 1); + [second updateProps:makeViewProps(false) oldProps:nullptr]; + XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount); +} + +- (void)testClippingViewCountAfterRecycleAndDealloc +{ + NSUInteger initialCount = RCTViewComponentViewClippingViewCount(); + @autoreleasepool { + RCTViewComponentView *view = [RCTViewComponentView new]; + [view updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()]; + XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 1); + + [view prepareForRecycle]; + XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount); + + [view updateProps:makeViewProps(true) oldProps:nullptr]; + XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 1); + } + // The view was deallocated with `removeClippedSubviews` on. + XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount); +} + #pragma mark - hitTest against non-invertible transforms (#50797) - (void)testHitTestReturnsNilForZeroScaleYView From eeb2f4092039626097b7c705816c46f06f798657 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 30 Oct 2026 15:27:55 +0100 Subject: [PATCH 2/2] 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 --- .../ScrollView/RCTScrollViewComponentView.mm | 27 ++- .../View/RCTViewComponentView.mm | 55 ++--- .../Mounting/RCTViewComponentViewTests.mm | 193 +++++++++++++++--- 3 files changed, 213 insertions(+), 62 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index e98a1ca9652d..fb531c090573 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -92,9 +92,6 @@ @interface RCTScrollViewComponentView () < @end -// Defined in RCTViewComponentView.mm. -NSUInteger RCTViewComponentViewClippingViewCount(void); - @implementation RCTScrollViewComponentView { ScrollViewShadowNode::ConcreteState::Shared _state; CGSize _contentSize; @@ -109,6 +106,9 @@ @implementation RCTScrollViewComponentView { BOOL _automaticallyAdjustKeyboardInsets; CGPoint _contentOffsetWhenClipped; + // Views with `removeClippedSubviews` on in a window whose nearest ScrollView is this one. + // See `_remountChildren`. + NSHashTable *_clippingViews; __weak UIView *_contentView; @@ -1010,8 +1010,10 @@ - (void)_remountChildren return; } - // The walk below only changes something at a view with `removeClippedSubviews` on. - if (RCTViewComponentViewClippingViewCount() == 0) { + // The walk below only changes something at a view with `removeClippedSubviews` on, and such + // views register with their nearest ScrollView once they are in a window. Outside a window, + // walk anyway: the content may be mounted before the ScrollView is shown. + if (self.window != nil && _clippingViews.count == 0) { return; } @@ -1019,6 +1021,21 @@ - (void)_remountChildren relativeToView:_scrollView]; } +// Called by `RCTViewComponentView` (see `_updateClippingScrollView`) for views with +// `removeClippedSubviews` on. +- (void)_registerClippingView:(UIView *)view +{ + if (_clippingViews == nil) { + _clippingViews = [NSHashTable weakObjectsHashTable]; + } + [_clippingViews addObject:view]; +} + +- (void)_unregisterClippingView:(UIView *)view +{ + [_clippingViews removeObject:view]; +} + #pragma mark - RCTScrollableProtocol - (CGSize)contentSize diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index f420cfaf59ee..2368cfc10edc 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -33,6 +33,8 @@ #import #import +#import + #ifdef RCT_DYNAMIC_FRAMEWORKS #import #endif @@ -105,24 +107,11 @@ static BOOL RCTViewIsInteractiveAccessibilityElement(UIView *view, const ViewPro } #endif -// The number of live views with `removeClippedSubviews` on. Only such views act on -// `updateClippedSubviewsWithClipRect:relativeToView:`, so while it is 0 a ScrollView -// can skip walking its subviews. Views are only created and changed on the main thread. -static NSUInteger RCTClippingViewCount = 0; - -NSUInteger RCTViewComponentViewClippingViewCount(void) -{ - return RCTClippingViewCount; -} - -static void RCTUpdateClippingViewCount(BOOL wasClipping, BOOL isClipping) -{ - if (isClipping && !wasClipping) { - RCTClippingViewCount++; - } else if (wasClipping && !isClipping) { - RCTClippingViewCount--; - } -} +// Implemented in RCTScrollViewComponentView.mm. +@interface RCTScrollViewComponentView (ClippingViews) +- (void)_registerClippingView:(UIView *)view; +- (void)_unregisterClippingView:(UIView *)view; +@end @implementation RCTViewComponentView { UIColor *_backgroundColor; @@ -136,6 +125,7 @@ @implementation RCTViewComponentView { BOOL _isJSResponder; BOOL _removeClippedSubviews; NSMutableArray *_reactSubviews; + __weak RCTScrollViewComponentView *_clippingScrollView; NSSet *_Nullable _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN; UIView *_containerView; BOOL _useCustomContainerView; @@ -385,9 +375,9 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & // Compare against the current state, not `_props`: `prepareForRecycle` resets // `_removeClippedSubviews` but keeps `_props`. if (_removeClippedSubviews != newViewProps.removeClippedSubviews) { - RCTUpdateClippingViewCount(_removeClippedSubviews, newViewProps.removeClippedSubviews); _removeClippedSubviews = newViewProps.removeClippedSubviews; [self _updateRemoveClippedSubviewsState]; + [self _updateClippingScrollView]; } } @@ -836,6 +826,26 @@ - (void)didMoveToWindow if ([self _observesSafeAreaInsets]) { [self setNeedsLayout]; } + [self _updateClippingScrollView]; +} + +// A ScrollView walks its content for views with `removeClippedSubviews` on (see +// `-[RCTScrollViewComponentView _remountChildren]`). Register this view with its nearest +// ScrollView while it has the prop on and is in a window, so that the ScrollView can skip +// the walk when it has no such views. A ScrollView doesn't register: the walk of an outer +// ScrollView stops at it, and it clips its own content. +- (void)_updateClippingScrollView +{ + BOOL needsClippingPass = + _removeClippedSubviews && self.window != nil && ![self isKindOfClass:[RCTScrollViewComponentView class]]; + RCTScrollViewComponentView *scrollView = + needsClippingPass ? [RCTScrollViewComponentView findScrollViewComponentViewForView:self] : nil; + if (scrollView == _clippingScrollView) { + return; + } + [_clippingScrollView _unregisterClippingView:self]; + [scrollView _registerClippingView:self]; + _clippingScrollView = scrollView; } - (void)layoutSubviews @@ -870,11 +880,6 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask [self invalidateLayer]; } -- (void)dealloc -{ - RCTUpdateClippingViewCount(_removeClippedSubviews, NO); -} - - (void)prepareForRecycle { [super prepareForRecycle]; @@ -912,8 +917,8 @@ - (void)prepareForRecycle _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN = nil; _eventEmitter.reset(); _isJSResponder = NO; - RCTUpdateClippingViewCount(_removeClippedSubviews, NO); _removeClippedSubviews = NO; + [self _updateClippingScrollView]; _reactSubviews = [NSMutableArray new]; _layoutMetrics = EmptyLayoutMetrics; } diff --git a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm index eace99c6738f..0de31ef8b675 100644 --- a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm +++ b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm @@ -5,8 +5,10 @@ * LICENSE file in the root directory of this source tree. */ +#import #import #import +#import #import #import #import @@ -20,8 +22,31 @@ return props; } -// Defined in RCTViewComponentView.mm. -NSUInteger RCTViewComponentViewClippingViewCount(void); +// The ScrollView's clipping pass, which runs after each mounting transaction and while scrolling. +@interface RCTScrollViewComponentView (Testing) +- (void)_remountChildren; +@end + +// Counts the clipping passes that reach it. +@interface RCTClippingPassSpyView : UIView +@property (nonatomic, assign) NSUInteger passCount; +@end + +@implementation RCTClippingPassSpyView +- (void)updateClippedSubviewsWithClipRect:(CGRect)clipRect relativeToView:(UIView *)clipView +{ + self.passCount++; +} +@end + +// A ScrollView, 400 points tall, with `content` as its content view. +static RCTScrollViewComponentView *makeScrollView(UIView *content) +{ + RCTScrollViewComponentView *scrollView = + [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 400)]; + [scrollView mountChildComponentView:(id)content index:0]; + return scrollView; +} @interface RCTViewComponentViewTests : XCTestCase @end @@ -166,46 +191,150 @@ - (void)testRemoveClippedSubviewsIsKeptOnRecycledView XCTAssertEqual(reactSubviews.count, 1u); } -#pragma mark - Clipping view count - -// The count is shared by all views, so each test compares it against its value at the start. +#pragma mark - ScrollView clipping pass -- (void)testClippingViewCountFollowsRemoveClippedSubviews +- (void)testScrollViewSkipsClippingPassWithoutClippingViews { - NSUInteger initialCount = RCTViewComponentViewClippingViewCount(); - RCTViewComponentView *first = [RCTViewComponentView new]; - RCTViewComponentView *second = [RCTViewComponentView new]; + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 400)]; + RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new]; + RCTScrollViewComponentView *scrollView = makeScrollView(spy); + [window addSubview:scrollView]; - [first updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()]; - [second updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()]; - XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 2); + [scrollView _remountChildren]; + XCTAssertEqual(spy.passCount, 0u); +} - // Props that keep `removeClippedSubviews` on don't count the view again. - [first updateProps:makeViewProps(true) oldProps:nullptr]; - XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 2); +- (void)testScrollViewOutsideWindowRunsClippingPass +{ + // Content can be mounted before its ScrollView is in a window, so the pass runs there as before. + RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new]; + RCTScrollViewComponentView *scrollView = makeScrollView(spy); - [first updateProps:makeViewProps(false) oldProps:nullptr]; - XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 1); - [second updateProps:makeViewProps(false) oldProps:nullptr]; - XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount); + [scrollView _remountChildren]; + XCTAssertEqual(spy.passCount, 1u); +} + +- (void)testClippingViewInWindowGetsClippingPass +{ + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 400)]; + RCTViewComponentView *content = [RCTViewComponentView new]; + content.frame = CGRectMake(0, 0, 100, 2000); + RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new]; + [content mountChildComponentView:(id)spy index:0]; + + // Mount the rows before turning clipping on, so that both start attached. + RCTViewComponentView *list = [RCTViewComponentView new]; + list.frame = CGRectMake(0, 0, 100, 2000); + UIView *visibleRow = [UIView new]; + visibleRow.frame = CGRectMake(0, 0, 100, 100); + UIView *offscreenRow = [UIView new]; + offscreenRow.frame = CGRectMake(0, 1500, 100, 100); + [list mountChildComponentView:(id)visibleRow index:0]; + [list mountChildComponentView:(id)offscreenRow index:1]; + [list updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()]; + [content mountChildComponentView:list index:1]; + XCTAssertEqual(offscreenRow.superview, list); + + RCTScrollViewComponentView *scrollView = makeScrollView(content); + [window addSubview:scrollView]; + + [scrollView _remountChildren]; + XCTAssertEqual(spy.passCount, 1u); + XCTAssertEqual(visibleRow.superview, list); + XCTAssertNil(offscreenRow.superview); } -- (void)testClippingViewCountAfterRecycleAndDealloc +- (void)testClippingPassFollowsClippingViewProps { - NSUInteger initialCount = RCTViewComponentViewClippingViewCount(); - @autoreleasepool { - RCTViewComponentView *view = [RCTViewComponentView new]; - [view updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()]; - XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 1); + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 400)]; + RCTViewComponentView *content = [RCTViewComponentView new]; + RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new]; + [content mountChildComponentView:(id)spy index:0]; + RCTViewComponentView *list = [RCTViewComponentView new]; + [list updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()]; + [content mountChildComponentView:list index:1]; + RCTScrollViewComponentView *scrollView = makeScrollView(content); + [window addSubview:scrollView]; + + [scrollView _remountChildren]; + XCTAssertEqual(spy.passCount, 1u); + + [list updateProps:makeViewProps(false) oldProps:nullptr]; + [scrollView _remountChildren]; + XCTAssertEqual(spy.passCount, 1u); + + [list updateProps:makeViewProps(true) oldProps:nullptr]; + [scrollView _remountChildren]; + XCTAssertEqual(spy.passCount, 2u); + + // A clipping view outside the window doesn't need the pass. + [content unmountChildComponentView:list index:1]; + [scrollView _remountChildren]; + XCTAssertEqual(spy.passCount, 2u); + + [content mountChildComponentView:list index:1]; + [scrollView _remountChildren]; + XCTAssertEqual(spy.passCount, 3u); + + // A recycled view starts without `removeClippedSubviews`. + [content unmountChildComponentView:list index:1]; + [list prepareForRecycle]; + [content mountChildComponentView:list index:1]; + [scrollView _remountChildren]; + XCTAssertEqual(spy.passCount, 3u); + + // Turned on outside the window, the view registers once it is attached. + [content unmountChildComponentView:list index:1]; + [list updateProps:makeViewProps(true) oldProps:nullptr]; + [content mountChildComponentView:list index:1]; + [scrollView _remountChildren]; + XCTAssertEqual(spy.passCount, 4u); +} - [view prepareForRecycle]; - XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount); +- (void)testClippingViewOnlyRegistersWithNearestScrollView +{ + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 400)]; + RCTViewComponentView *innerContent = [RCTViewComponentView new]; + RCTClippingPassSpyView *innerSpy = [RCTClippingPassSpyView new]; + [innerContent mountChildComponentView:(id)innerSpy index:0]; + RCTViewComponentView *list = [RCTViewComponentView new]; + [list updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()]; + [innerContent mountChildComponentView:list index:1]; + RCTScrollViewComponentView *innerScrollView = makeScrollView(innerContent); + + RCTViewComponentView *content = [RCTViewComponentView new]; + RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new]; + [content mountChildComponentView:(id)spy index:0]; + [content mountChildComponentView:innerScrollView index:1]; + RCTScrollViewComponentView *outerScrollView = makeScrollView(content); + [window addSubview:outerScrollView]; + + // The outer pass stops at the inner ScrollView, which handles `list` itself. + [outerScrollView _remountChildren]; + XCTAssertEqual(spy.passCount, 0u); + [innerScrollView _remountChildren]; + XCTAssertEqual(innerSpy.passCount, 1u); +} - [view updateProps:makeViewProps(true) oldProps:nullptr]; - XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount + 1); - } - // The view was deallocated with `removeClippedSubviews` on. - XCTAssertEqual(RCTViewComponentViewClippingViewCount(), initialCount); +- (void)testScrollViewWithRemoveClippedSubviewsDoesNotRegisterWithOuterScrollView +{ + // For example a horizontal list inside a vertical one: ScrollView forwards the prop to the + // native ScrollView too, but the inner ScrollView clips its own content. + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 400)]; + RCTScrollViewComponentView *innerScrollView = makeScrollView([RCTViewComponentView new]); + auto innerProps = std::make_shared(); + innerProps->removeClippedSubviews = true; + [innerScrollView updateProps:innerProps oldProps:ScrollViewShadowNode::defaultSharedProps()]; + + RCTViewComponentView *content = [RCTViewComponentView new]; + RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new]; + [content mountChildComponentView:(id)spy index:0]; + [content mountChildComponentView:innerScrollView index:1]; + RCTScrollViewComponentView *outerScrollView = makeScrollView(content); + [window addSubview:outerScrollView]; + + [outerScrollView _remountChildren]; + XCTAssertEqual(spy.passCount, 0u); } #pragma mark - hitTest against non-invertible transforms (#50797)