From 9c2bb946c25d88be7de3a746e65f06509a620589 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 9 Apr 2026 14:04:52 -0700 Subject: [PATCH 1/2] fix(fabric,macos): force overlay scrollbar style in ScrollView Force NSScrollerStyleOverlay on Fabric ScrollViews to avoid layout issues with legacy (always-visible) scrollbars. Legacy scrollbars sit inside the NSScrollView frame and reduce the clip view's visible area, which would require compensating padding in the Yoga shadow tree. Overlay scrollers float above content, so no layout compensation is needed. Additional fixes: - Remove autoresizingMask from documentView to prevent AppKit frame corruption during tile/resize - Remove the layoutSubviews workaround (no longer needed without autoresizingMask) - Add [_scrollView tile] after content size updates to re-evaluate scroller visibility - Fix hit testing: check NSScroller before content subviews so scrollbar clicks aren't swallowed by full-width content views Fixes #2857 Co-Authored-By: Claude Opus 4.6 --- .../ScrollView/RCTScrollViewComponentView.mm | 64 +++++++++++++------ 1 file changed, 43 insertions(+), 21 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 4a80bdf5b26b..d552193056fc 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -157,7 +157,16 @@ - (instancetype)initWithFrame:(CGRect)frame #if !TARGET_OS_OSX // [macOS] [_scrollView addSubview:_containerView]; #else // [macOS - _containerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + // Force overlay scrollbar style. Overlay scrollers float above content and + // don't reduce the clip view's visible area, so no layout compensation is + // needed. Legacy (always-visible) scrollbars sit inside the frame and would + // require padding adjustments in the Yoga shadow tree — avoiding that + // complexity is the main motivation for forcing overlay style. + _scrollView.scrollerStyle = NSScrollerStyleOverlay; + // Do NOT set autoresizingMask on the documentView. AppKit's autoresizing + // corrupts the documentView frame during tile/resize (adding the clip view's + // size delta to the container, inflating it beyond the actual content size). + // React manages the documentView frame directly via updateState:. [_scrollView setDocumentView:_containerView]; #endif // macOS] @@ -185,26 +194,12 @@ - (void)dealloc } #if TARGET_OS_OSX // [macOS -- (void)layoutSubviews -{ - [super layoutSubviews]; - - // On macOS, the _containerView is the NSScrollView's documentView and has autoresizingMask set so - // it fills the visible area before React's first layout pass. However, AppKit's autoresizing can - // corrupt the documentView's frame by adding the NSClipView's size delta to the container's - // dimensions (e.g., during initial tile or window resize), inflating it well beyond the correct - // content size. This produces massive horizontal and vertical overflow on first render. - // - // After React has set the content size via updateState:, we reset the documentView frame here to - // undo any autoresizing corruption. This runs after AppKit's layout (which triggers autoresizing), - // so it reliably corrects the frame. - if (!CGSizeEqualToSize(_contentSize, CGSizeZero)) { - CGRect containerFrame = _containerView.frame; - if (!CGSizeEqualToSize(containerFrame.size, _contentSize)) { - containerFrame.size = _contentSize; - _containerView.frame = containerFrame; - } - } +- (void)_preferredScrollerStyleDidChange:(NSNotification *)notification +{ + // The user changed the system "Show scroll bars" preference. Re-force + // overlay style so legacy scrollbars don't appear and cause layout issues. + _scrollView.scrollerStyle = NSScrollerStyleOverlay; + [_scrollView tile]; } #endif // macOS] @@ -558,6 +553,11 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared & [self _preserveContentOffsetIfNeededWithBlock:^{ self->_scrollView.contentSize = contentSize; }]; + +#if TARGET_OS_OSX // [macOS + // Force the scroll view to re-evaluate which scrollers should be visible. + [_scrollView tile]; +#endif // macOS] } - (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event // [macOS] @@ -585,6 +585,20 @@ - (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event // return nil; } +#if TARGET_OS_OSX // [macOS + // Check if the hit lands on a scrollbar (NSScroller) BEFORE checking content + // subviews. Scrollers are subviews of the NSScrollView, not the documentView + // (_containerView). They must be checked first because content views typically + // fill the entire visible area and would otherwise swallow scroller clicks. + if (isPointInside) { + NSPoint scrollViewPoint = [_scrollView convertPoint:point fromView:self]; + NSView *scrollViewHit = [_scrollView hitTest:scrollViewPoint]; + if ([scrollViewHit isKindOfClass:[NSScroller class]]) { + return (RCTPlatformView *)scrollViewHit; + } + } +#endif // macOS] + for (RCTPlatformView *subview in [_containerView.subviews reverseObjectEnumerator]) { // [macOS] RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, point, self, event); // [macOS] if (hitView) { @@ -889,12 +903,20 @@ - (void)viewDidMoveToWindow // [macOS] [defaultCenter removeObserver:self name:NSViewBoundsDidChangeNotification object:_scrollView.contentView]; + [defaultCenter removeObserver:self + name:NSPreferredScrollerStyleDidChangeNotification + object:nil]; } else { // Register for scrollview's clipview bounds change notifications so we can track scrolling [defaultCenter addObserver:self selector:@selector(scrollViewDocumentViewBoundsDidChange:) name:NSViewBoundsDidChangeNotification object:_scrollView.contentView]; // NSClipView + // Re-force overlay style if the user changes the system "Show scroll bars" preference + [defaultCenter addObserver:self + selector:@selector(_preferredScrollerStyleDidChange:) + name:NSPreferredScrollerStyleDidChangeNotification + object:nil]; } #endif // macOS] From 0870abebad29c95ba74d87e82ca5da1e90b3eee2 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 10 Apr 2026 20:30:50 -0700 Subject: [PATCH 2/2] refactor: address PR review comments - Minimize verbose comments throughout - Remove explicit [_scrollView tile] call in updateState: (unnecessary with overlay scrollbar style) Co-Authored-By: Claude Opus 4.6 --- .../ScrollView/RCTScrollViewComponentView.mm | 26 ++++--------------- 1 file changed, 5 insertions(+), 21 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 d552193056fc..a14a8641e13d 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -157,16 +157,9 @@ - (instancetype)initWithFrame:(CGRect)frame #if !TARGET_OS_OSX // [macOS] [_scrollView addSubview:_containerView]; #else // [macOS - // Force overlay scrollbar style. Overlay scrollers float above content and - // don't reduce the clip view's visible area, so no layout compensation is - // needed. Legacy (always-visible) scrollbars sit inside the frame and would - // require padding adjustments in the Yoga shadow tree — avoiding that - // complexity is the main motivation for forcing overlay style. + // Force overlay scrollbar style to avoid layout issues with legacy scrollbars. _scrollView.scrollerStyle = NSScrollerStyleOverlay; - // Do NOT set autoresizingMask on the documentView. AppKit's autoresizing - // corrupts the documentView frame during tile/resize (adding the clip view's - // size delta to the container, inflating it beyond the actual content size). - // React manages the documentView frame directly via updateState:. + // Don't set autoresizingMask — AppKit corrupts the documentView frame during tile/resize. [_scrollView setDocumentView:_containerView]; #endif // macOS] @@ -196,8 +189,7 @@ - (void)dealloc #if TARGET_OS_OSX // [macOS - (void)_preferredScrollerStyleDidChange:(NSNotification *)notification { - // The user changed the system "Show scroll bars" preference. Re-force - // overlay style so legacy scrollbars don't appear and cause layout issues. + // Re-force overlay style when system preference changes. _scrollView.scrollerStyle = NSScrollerStyleOverlay; [_scrollView tile]; } @@ -553,11 +545,6 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared & [self _preserveContentOffsetIfNeededWithBlock:^{ self->_scrollView.contentSize = contentSize; }]; - -#if TARGET_OS_OSX // [macOS - // Force the scroll view to re-evaluate which scrollers should be visible. - [_scrollView tile]; -#endif // macOS] } - (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event // [macOS] @@ -586,10 +573,8 @@ - (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event // } #if TARGET_OS_OSX // [macOS - // Check if the hit lands on a scrollbar (NSScroller) BEFORE checking content - // subviews. Scrollers are subviews of the NSScrollView, not the documentView - // (_containerView). They must be checked first because content views typically - // fill the entire visible area and would otherwise swallow scroller clicks. + // Check scrollbars before content subviews — scrollers are NSScrollView children, + // not documentView children, so full-width content would swallow their clicks. if (isPointInside) { NSPoint scrollViewPoint = [_scrollView convertPoint:point fromView:self]; NSView *scrollViewHit = [_scrollView hitTest:scrollViewPoint]; @@ -912,7 +897,6 @@ - (void)viewDidMoveToWindow // [macOS] selector:@selector(scrollViewDocumentViewBoundsDidChange:) name:NSViewBoundsDidChangeNotification object:_scrollView.contentView]; // NSClipView - // Re-force overlay style if the user changes the system "Show scroll bars" preference [defaultCenter addObserver:self selector:@selector(_preferredScrollerStyleDidChange:) name:NSPreferredScrollerStyleDidChangeNotification