From 04e8006816540692872063ea13adc7b2f9fa83ad Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 1 Apr 2026 16:08:26 -0700 Subject: [PATCH 1/2] [macOS] Fix ScrollView layout with legacy (always-visible) scrollbars (Fabric) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, legacy scrollbars sit inside the NSScrollView frame and reduce the NSClipView's visible area. React's layout system measures children against the full frame, causing content to overflow behind the scrollbar. Following the pattern from facebook/react-native#53247 (Switch), this fix reads the scrollbar width synchronously during the Yoga layout pass via a cached atomic value — no state round-trip, so the first layout is immediately correct. Key changes: - Add ScrollViewShadowNode::setSystemScrollbarWidth() / getSystemScrollbarWidth() backed by a static atomic, called from native code at class load time - applyScrollbarPadding() reads the cached value directly (not from state) - Remove scrollbarTrailingWidth/scrollbarBottomHeight from ScrollViewState - Remove autoresizingMask from documentView (prevents AppKit frame corruption) - Move NSScroller hit test check before subview loop (fixes scrollbar clicks) - Observe NSPreferredScrollerStyleDidChangeNotification for runtime changes Co-Authored-By: Claude Opus 4.6 --- .../ScrollView/RCTScrollViewComponentView.mm | 90 ++++++++++++++++++- .../scrollview/ScrollViewShadowNode.cpp | 76 ++++++++++++++++ .../scrollview/ScrollViewShadowNode.h | 17 +++- 3 files changed, 181 insertions(+), 2 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 8c309adba96c..ffd3ac869f03 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,10 @@ - (instancetype)initWithFrame:(CGRect)frame #if !TARGET_OS_OSX // [macOS] [_scrollView addSubview:_containerView]; #else // [macOS - _containerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + // 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] @@ -184,6 +187,63 @@ - (void)dealloc #endif // [macOS] } +#if TARGET_OS_OSX // [macOS ++ (void)initialize +{ + if (self == [RCTScrollViewComponentView class]) { + // Pre-warm the cached scrollbar width at class load time, before any + // layout occurs. This ensures the first Yoga layout pass already knows + // the correct scrollbar dimensions — no state round-trip required. + [self _updateCachedScrollbarWidth]; + + // Observe system scrollbar style changes so we can update the cached + // value and trigger re-layout when the preference changes. + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(_systemScrollerStyleDidChange:) + name:NSPreferredScrollerStyleDidChangeNotification + object:nil]; + } +} + ++ (void)_updateCachedScrollbarWidth +{ + CGFloat width = 0; + if ([NSScroller preferredScrollerStyle] == NSScrollerStyleLegacy) { + width = [NSScroller scrollerWidthForControlSize:NSControlSizeRegular + scrollerStyle:NSScrollerStyleLegacy]; + } + ScrollViewShadowNode::setSystemScrollbarWidth(static_cast(width)); +} + ++ (void)_systemScrollerStyleDidChange:(NSNotification *)notification +{ + [self _updateCachedScrollbarWidth]; +} + +- (void)_preferredScrollerStyleDidChange:(NSNotification *)notification +{ + // Update the native scroll view's scroller style and re-tile so scrollers + // are properly created/removed. + _scrollView.scrollerStyle = [NSScroller preferredScrollerStyle]; + [_scrollView tile]; + + // Force a state update to trigger shadow tree re-clone. The cloned + // ScrollViewShadowNode will read the updated cached scrollbar width + // in applyScrollbarPadding() and re-layout with correct padding. + if (_state) { + _state->updateState( + [](const ScrollViewShadowNode::ConcreteState::Data &oldData) + -> ScrollViewShadowNode::ConcreteState::SharedData { + auto newData = oldData; + // Reset contentBoundingRect to force a state difference + newData.contentBoundingRect = {}; + return std::make_shared(newData); + }); + } +} +#endif // macOS] + #if TARGET_OS_IOS - (void)_registerKeyboardListener { @@ -534,6 +594,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] @@ -561,6 +626,21 @@ - (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 — + // for both overlay and legacy (always-visible) scrollbar styles. + 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) { @@ -865,12 +945,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 + // Observe system scrollbar style changes so we can update scrollbar insets for Yoga layout + [defaultCenter addObserver:self + selector:@selector(_preferredScrollerStyleDidChange:) + name:NSPreferredScrollerStyleDidChangeNotification + object:nil]; } #endif // macOS] diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp index d0ee75cd73fd..a86c3d996e98 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp @@ -7,13 +7,89 @@ #include "ScrollViewShadowNode.h" +#include #include +#include #include +#include namespace facebook::react { const char ScrollViewComponentName[] = "ScrollView"; +// [macOS] Cached system scrollbar width, set from native code at startup and +// when the system "Show scroll bars" preference changes. Read synchronously +// during Yoga layout so the first pass is immediately correct. +static std::atomic systemScrollbarWidth_{0.0f}; + +void ScrollViewShadowNode::setSystemScrollbarWidth(Float width) { + systemScrollbarWidth_.store( + static_cast(width), std::memory_order_relaxed); +} + +Float ScrollViewShadowNode::getSystemScrollbarWidth() { + return static_cast( + systemScrollbarWidth_.load(std::memory_order_relaxed)); +} + +ScrollViewShadowNode::ScrollViewShadowNode( + const ShadowNodeFragment& fragment, + const ShadowNodeFamily::Shared& family, + ShadowNodeTraits traits) + : ConcreteViewShadowNode(fragment, family, traits) { + applyScrollbarPadding(); +} + +ScrollViewShadowNode::ScrollViewShadowNode( + const ShadowNode& sourceShadowNode, + const ShadowNodeFragment& fragment) + : ConcreteViewShadowNode(sourceShadowNode, fragment) { + applyScrollbarPadding(); +} + +void ScrollViewShadowNode::applyScrollbarPadding() { + // [macOS] On macOS, legacy (always-visible) scrollbars sit inside the + // NSScrollView frame and reduce the NSClipView's visible area. Without + // adjustment, Yoga lays out children against the full ScrollView width, + // causing content to overflow the visible area. + // + // Read the scrollbar width directly from the cached system value (set by + // native code via setSystemScrollbarWidth). This is synchronous — no state + // round-trip — so the first layout pass is immediately correct. + // + // IMPORTANT: We read the base padding from props (not the current Yoga + // style) to avoid double-counting. When a shadow node is cloned, the Yoga + // style is copied from the source node which may already include scrollbar + // padding from a previous applyScrollbarPadding() call. + Float scrollbarWidth = getSystemScrollbarWidth(); + + const auto& props = static_cast(*props_); + const auto& propsStyle = props.yogaStyle; + + auto style = yogaNode_.style(); + bool changed = false; + + // Compute target right padding: base from props + scrollbar width + { + auto basePadding = propsStyle.padding(yoga::Edge::Right); + Float baseValue = 0; + if (basePadding.isDefined() && basePadding.isPoints()) { + baseValue = basePadding.value().unwrap(); + } + Float targetValue = baseValue + scrollbarWidth; + auto targetPadding = yoga::StyleLength::points(targetValue); + if (targetPadding != style.padding(yoga::Edge::Right)) { + style.setPadding(yoga::Edge::Right, targetPadding); + changed = true; + } + } + + if (changed) { + yogaNode_.setStyle(style); + yogaNode_.setDirty(true); + } +} + void ScrollViewShadowNode::updateStateIfNeeded() { ensureUnsealed(); diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.h b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.h index a0fbcf4ca1e9..702622a112ac 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.h +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.h @@ -27,13 +27,27 @@ class ScrollViewShadowNode final : public ConcreteViewShadowNode< ScrollViewEventEmitter, ScrollViewState> { public: - using ConcreteViewShadowNode::ConcreteViewShadowNode; + ScrollViewShadowNode( + const ShadowNodeFragment& fragment, + const ShadowNodeFamily::Shared& family, + ShadowNodeTraits traits); + ScrollViewShadowNode( + const ShadowNode& sourceShadowNode, + const ShadowNodeFragment& fragment); static ScrollViewState initialStateData( const Props::Shared& props, const ShadowNodeFamily::Shared& family, const ComponentDescriptor& componentDescriptor); + // [macOS] Set the system scrollbar width (e.g. legacy scroller width on + // macOS). Called from native code at startup and when the system "Show scroll + // bars" preference changes. The value is read synchronously during Yoga + // layout, so the first layout pass is immediately correct — no state + // round-trip required. Thread-safe (uses atomic store/load). + static void setSystemScrollbarWidth(Float width); + static Float getSystemScrollbarWidth(); + #pragma mark - LayoutableShadowNode void layout(LayoutContext layoutContext) override; @@ -42,6 +56,7 @@ class ScrollViewShadowNode final : public ConcreteViewShadowNode< private: void updateStateIfNeeded(); void updateScrollContentOffsetIfNeeded(); + void applyScrollbarPadding(); }; } // namespace facebook::react From fe5efee2f546b86b318ac22c4e250900d8fff601 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 1 Apr 2026 17:16:38 -0700 Subject: [PATCH 2/2] [macOS] Fix Paper ScrollView layout with legacy scrollbars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply paddingEnd on the ScrollView's shadow node to account for legacy scrollbar width, matching the Fabric approach. A new RCTScrollViewShadowView (defined inline in RCTScrollViewManager.m) reads +[NSScroller preferredScrollerStyle] and +scrollerWidthForControlSize: directly during layoutWithMetrics: — these are thread-safe class methods, so no localData round-trip is needed. Key changes: - Add RCTScrollViewShadowView with paddingEnd for scrollbar width - Revert RCTScrollContentShadowView to stock (only RTL fix remains) - Delete RCTScrollContentLocalData files (no longer used) - Update preferredScrollerStyleDidChange: in RCTScrollView to re-tile and trigger content size update on system preference changes Co-Authored-By: Claude Opus 4.6 --- .../MacOS/RCTScrollContentLocalData.h | 27 --------- .../MacOS/RCTScrollContentLocalData.m | 26 --------- .../ScrollView/RCTScrollContentShadowView.m | 18 ------ .../Views/ScrollView/RCTScrollContentView.m | 20 ------- .../React/Views/ScrollView/RCTScrollView.m | 8 +++ .../Views/ScrollView/RCTScrollViewManager.m | 55 +++++++++++++++++++ 6 files changed, 63 insertions(+), 91 deletions(-) delete mode 100644 packages/react-native/React/Views/ScrollView/MacOS/RCTScrollContentLocalData.h delete mode 100644 packages/react-native/React/Views/ScrollView/MacOS/RCTScrollContentLocalData.m diff --git a/packages/react-native/React/Views/ScrollView/MacOS/RCTScrollContentLocalData.h b/packages/react-native/React/Views/ScrollView/MacOS/RCTScrollContentLocalData.h deleted file mode 100644 index 3230502b5701..000000000000 --- a/packages/react-native/React/Views/ScrollView/MacOS/RCTScrollContentLocalData.h +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -// [macOS] - -#if TARGET_OS_OSX -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface RCTScrollContentLocalData : NSObject - -@property (nonatomic, assign) CGFloat horizontalScrollerHeight; -@property (nonatomic, assign) CGFloat verticalScrollerWidth; - -- (instancetype)initWithVerticalScrollerWidth:(CGFloat)verticalScrollerWidth - horizontalScrollerHeight:(CGFloat)horizontalScrollerHeight; - -@end - -NS_ASSUME_NONNULL_END - -#endif // [macOS] diff --git a/packages/react-native/React/Views/ScrollView/MacOS/RCTScrollContentLocalData.m b/packages/react-native/React/Views/ScrollView/MacOS/RCTScrollContentLocalData.m deleted file mode 100644 index d2da7e0a6071..000000000000 --- a/packages/react-native/React/Views/ScrollView/MacOS/RCTScrollContentLocalData.m +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -// [macOS] - -#if TARGET_OS_OSX -#import "RCTScrollContentLocalData.h" - -@implementation RCTScrollContentLocalData - -- (instancetype)initWithVerticalScrollerWidth:(CGFloat)verticalScrollerWidth - horizontalScrollerHeight:(CGFloat)horizontalScrollerHeight; -{ - if (self = [super init]) { - _verticalScrollerWidth = verticalScrollerWidth; - _horizontalScrollerHeight = horizontalScrollerHeight; - } - return self; -} - -@end -#endif \ No newline at end of file diff --git a/packages/react-native/React/Views/ScrollView/RCTScrollContentShadowView.m b/packages/react-native/React/Views/ScrollView/RCTScrollContentShadowView.m index e62bd7287ab5..c892556c6aa1 100644 --- a/packages/react-native/React/Views/ScrollView/RCTScrollContentShadowView.m +++ b/packages/react-native/React/Views/ScrollView/RCTScrollContentShadowView.m @@ -9,28 +9,10 @@ #import -#if TARGET_OS_OSX // [macOS -#import "RCTScrollContentLocalData.h" -#endif // macOS] - #import "RCTUtils.h" @implementation RCTScrollContentShadowView -#if TARGET_OS_OSX // [macOS -- (void)setLocalData:(RCTScrollContentLocalData *)localData -{ - RCTAssert( - [localData isKindOfClass:[RCTScrollContentLocalData class]], - @"Local data object for `RCTScrollContentView` must be `RCTScrollContentLocalData` instance."); - - super.marginEnd = (YGValue){localData.verticalScrollerWidth, YGUnitPoint}; - super.marginBottom = (YGValue){localData.horizontalScrollerHeight, YGUnitPoint}; - - [self didSetProps:@[@"marginEnd", @"marginBottom"]]; -} -#endif // macOS] - - (void)layoutWithMetrics:(RCTLayoutMetrics)layoutMetrics layoutContext:(RCTLayoutContext)layoutContext { if (layoutMetrics.layoutDirection == UIUserInterfaceLayoutDirectionRightToLeft) { diff --git a/packages/react-native/React/Views/ScrollView/RCTScrollContentView.m b/packages/react-native/React/Views/ScrollView/RCTScrollContentView.m index 134d7217f474..8d856848883d 100644 --- a/packages/react-native/React/Views/ScrollView/RCTScrollContentView.m +++ b/packages/react-native/React/Views/ScrollView/RCTScrollContentView.m @@ -10,11 +10,6 @@ #import #import -#if TARGET_OS_OSX // [macOS -#import -#import "RCTScrollContentLocalData.h" -#endif // macOS] - #import "RCTScrollView.h" @implementation RCTScrollContentView @@ -44,22 +39,7 @@ - (void)reactSetFrame:(CGRect)frame [scrollView updateContentSizeIfNeeded]; #if TARGET_OS_OSX // [macOS - // On macOS scroll indicators may float over the content view like they do in iOS - // or depending on system preferences they may be outside of the content view - // which means the clip view will be smaller than the scroll view itself. - // In such cases the content view layout must shrink accordingly otherwise - // the contents will overflow causing the scroll indicators to appear unnecessarily. NSScrollView *platformScrollView = [scrollView scrollView]; - if ([platformScrollView scrollerStyle] == NSScrollerStyleLegacy) { - BOOL contentHasHeight = platformScrollView.contentSize.height > 0; - CGFloat horizontalScrollerHeight = ([platformScrollView hasHorizontalScroller] && contentHasHeight) ? NSHeight([[platformScrollView horizontalScroller] frame]) : 0; - CGFloat verticalScrollerWidth = [platformScrollView hasVerticalScroller] ? NSWidth([[platformScrollView verticalScroller] frame]) : 0; - - RCTScrollContentLocalData *localData = [[RCTScrollContentLocalData alloc] initWithVerticalScrollerWidth:verticalScrollerWidth horizontalScrollerHeight:horizontalScrollerHeight]; - - [[[scrollView bridge] uiManager] setLocalData:localData forView:self]; - } - if ([platformScrollView accessibilityRole] == NSAccessibilityTableRole) { NSMutableArray *subViews = [[NSMutableArray alloc] initWithCapacity:[[self subviews] count]]; for (NSView *view in [self subviews]) { diff --git a/packages/react-native/React/Views/ScrollView/RCTScrollView.m b/packages/react-native/React/Views/ScrollView/RCTScrollView.m index bbf95176f839..37dae46434aa 100644 --- a/packages/react-native/React/Views/ScrollView/RCTScrollView.m +++ b/packages/react-native/React/Views/ScrollView/RCTScrollView.m @@ -1431,6 +1431,14 @@ - (void)keyUp:(NSEvent *)event { - (void)preferredScrollerStyleDidChange:(__unused NSNotification *)notification { RCT_SEND_SCROLL_EVENT(onPreferredScrollerStyleDidChange, (@{ @"preferredScrollerStyle": RCTStringForScrollerStyle([NSScroller preferredScrollerStyle])})); + + // When the system scrollbar style changes, force the scroll view to adopt the + // new style, re-tile, and trigger a content size update. The ScrollView's + // shadow view (RCTScrollViewShadowView) will detect the new scroller style on + // the next layout pass and update its padding accordingly. + _scrollView.scrollerStyle = [NSScroller preferredScrollerStyle]; + [_scrollView tile]; + [self updateContentSizeIfNeeded]; } #endif // macOS] diff --git a/packages/react-native/React/Views/ScrollView/RCTScrollViewManager.m b/packages/react-native/React/Views/ScrollView/RCTScrollViewManager.m index fbd215c2b3d8..dd1c63b8f8d0 100644 --- a/packages/react-native/React/Views/ScrollView/RCTScrollViewManager.m +++ b/packages/react-native/React/Views/ScrollView/RCTScrollViewManager.m @@ -12,6 +12,54 @@ #import "RCTShadowView.h" #import "RCTUIManager.h" +#if TARGET_OS_OSX // [macOS +// Custom shadow view for ScrollView on macOS. Applies paddingEnd to account for +// legacy (always-visible) scrollbar width so Yoga lays out children within the +// actual visible area (clip view), not the full ScrollView frame. +@interface RCTScrollViewShadowView : RCTShadowView +@end + +@implementation RCTScrollViewShadowView + +- (instancetype)init +{ + if (self = [super init]) { + // Set scrollbar padding immediately so it's in place before the first Yoga + // layout pass. Without this, the first pass uses full width, then a second + // pass corrects it — causing a visible flicker. + [self applyScrollbarPadding]; + } + return self; +} + +- (void)applyScrollbarPadding +{ + CGFloat verticalScrollerWidth = 0; + if ([NSScroller preferredScrollerStyle] == NSScrollerStyleLegacy) { + verticalScrollerWidth = [NSScroller scrollerWidthForControlSize:NSControlSizeRegular + scrollerStyle:NSScrollerStyleLegacy]; + } + + YGValue currentPaddingEnd = super.paddingEnd; + BOOL needsUpdate = + (currentPaddingEnd.unit != YGUnitPoint || currentPaddingEnd.value != verticalScrollerWidth); + + if (needsUpdate) { + super.paddingEnd = (YGValue){verticalScrollerWidth, YGUnitPoint}; + [self didSetProps:@[@"paddingEnd"]]; + } +} + +- (void)layoutWithMetrics:(RCTLayoutMetrics)layoutMetrics layoutContext:(RCTLayoutContext)layoutContext +{ + // Re-check on every layout pass in case the system scroller style changed. + [self applyScrollbarPadding]; + [super layoutWithMetrics:layoutMetrics layoutContext:layoutContext]; +} + +@end +#endif // macOS] + #if !TARGET_OS_OSX // [macOS] @implementation RCTConvert (UIScrollView) @@ -62,6 +110,13 @@ - (RCTPlatformView *)view // [macOS] return [[RCTScrollView alloc] initWithEventDispatcher:self.bridge.eventDispatcher]; } +#if TARGET_OS_OSX // [macOS +- (RCTShadowView *)shadowView +{ + return [RCTScrollViewShadowView new]; +} +#endif // macOS] + RCT_EXPORT_VIEW_PROPERTY(alwaysBounceHorizontal, BOOL) RCT_EXPORT_VIEW_PROPERTY(alwaysBounceVertical, BOOL) RCT_EXPORT_NOT_OSX_VIEW_PROPERTY(bounces, BOOL) // [macOS]