Skip to content

Commit e395913

Browse files
Saadnajmiclaude
andauthored
fix(0.81, fabric): force overlay scrollbar style in ScrollView (#2908)
## Summary Backport of #2907 to `0.81-stable`. - Force `NSScrollerStyleOverlay` on Fabric ScrollViews to fix layout overflow on first render - Remove `autoresizingMask` from documentView to prevent AppKit frame corruption - Fix scrollbar click hit testing (check `NSScroller` before content subviews) - Re-force overlay style when the system "Show scroll bars" preference changes at runtime ## Why force overlay instead of supporting legacy scrollbars? 1. **Every other platform uses overlay scrollbars.** iOS, Android, and web all render scrollbar indicators that float above content. Forcing overlay aligns macOS with every other React Native platform. 2. **Supporting legacy scrollbars required invasive changes to ReactCommon.** The alternative required cached atomic values, custom shadow node constructors, and padding adjustments in the Yoga layout pass — a significant cross-platform change for a single-platform edge case. 3. **Apple themselves call non-overlay scrollbars "legacy."** The API is literally `NSScrollerStyleLegacy`. Mac Catalyst doesn't even respect this preference (always uses overlay). ## Test plan - [x] Verified on macOS with "Show scroll bars: Always" — no overflow on first render - [x] Verified switching system preference at runtime doesn't bring back legacy scrollbars - [x] Verified scrollbar clicks work - [x] Verified window resize doesn't cause overflow Fixes #2857 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 764c692 commit e395913

File tree

1 file changed

+27
-21
lines changed

1 file changed

+27
-21
lines changed

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

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,9 @@ - (instancetype)initWithFrame:(CGRect)frame
157157
#if !TARGET_OS_OSX // [macOS]
158158
[_scrollView addSubview:_containerView];
159159
#else // [macOS
160-
_containerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
160+
// Force overlay scrollbar style to avoid layout issues with legacy scrollbars.
161+
_scrollView.scrollerStyle = NSScrollerStyleOverlay;
162+
// Don't set autoresizingMask — AppKit corrupts the documentView frame during tile/resize.
161163
[_scrollView setDocumentView:_containerView];
162164
#endif // macOS]
163165

@@ -185,26 +187,11 @@ - (void)dealloc
185187
}
186188

187189
#if TARGET_OS_OSX // [macOS
188-
- (void)layoutSubviews
189-
{
190-
[super layoutSubviews];
191-
192-
// On macOS, the _containerView is the NSScrollView's documentView and has autoresizingMask set so
193-
// it fills the visible area before React's first layout pass. However, AppKit's autoresizing can
194-
// corrupt the documentView's frame by adding the NSClipView's size delta to the container's
195-
// dimensions (e.g., during initial tile or window resize), inflating it well beyond the correct
196-
// content size. This produces massive horizontal and vertical overflow on first render.
197-
//
198-
// After React has set the content size via updateState:, we reset the documentView frame here to
199-
// undo any autoresizing corruption. This runs after AppKit's layout (which triggers autoresizing),
200-
// so it reliably corrects the frame.
201-
if (!CGSizeEqualToSize(_contentSize, CGSizeZero)) {
202-
CGRect containerFrame = _containerView.frame;
203-
if (!CGSizeEqualToSize(containerFrame.size, _contentSize)) {
204-
containerFrame.size = _contentSize;
205-
_containerView.frame = containerFrame;
206-
}
207-
}
190+
- (void)_preferredScrollerStyleDidChange:(NSNotification *)notification
191+
{
192+
// Re-force overlay style when system preference changes.
193+
_scrollView.scrollerStyle = NSScrollerStyleOverlay;
194+
[_scrollView tile];
208195
}
209196
#endif // macOS]
210197

@@ -585,6 +572,18 @@ - (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event //
585572
return nil;
586573
}
587574

575+
#if TARGET_OS_OSX // [macOS
576+
// Check scrollbars before content subviews — scrollers are NSScrollView children,
577+
// not documentView children, so full-width content would swallow their clicks.
578+
if (isPointInside) {
579+
NSPoint scrollViewPoint = [_scrollView convertPoint:point fromView:self];
580+
NSView *scrollViewHit = [_scrollView hitTest:scrollViewPoint];
581+
if ([scrollViewHit isKindOfClass:[NSScroller class]]) {
582+
return (RCTPlatformView *)scrollViewHit;
583+
}
584+
}
585+
#endif // macOS]
586+
588587
for (RCTPlatformView *subview in [_containerView.subviews reverseObjectEnumerator]) { // [macOS]
589588
RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, point, self, event); // [macOS]
590589
if (hitView) {
@@ -889,12 +888,19 @@ - (void)viewDidMoveToWindow // [macOS]
889888
[defaultCenter removeObserver:self
890889
name:NSViewBoundsDidChangeNotification
891890
object:_scrollView.contentView];
891+
[defaultCenter removeObserver:self
892+
name:NSPreferredScrollerStyleDidChangeNotification
893+
object:nil];
892894
} else {
893895
// Register for scrollview's clipview bounds change notifications so we can track scrolling
894896
[defaultCenter addObserver:self
895897
selector:@selector(scrollViewDocumentViewBoundsDidChange:)
896898
name:NSViewBoundsDidChangeNotification
897899
object:_scrollView.contentView]; // NSClipView
900+
[defaultCenter addObserver:self
901+
selector:@selector(_preferredScrollerStyleDidChange:)
902+
name:NSPreferredScrollerStyleDidChangeNotification
903+
object:nil];
898904
}
899905
#endif // macOS]
900906

0 commit comments

Comments
 (0)