From e7202bc55924d27915921c8ce68f714ae207dc91 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 25 Mar 2026 19:33:51 -0500 Subject: [PATCH 1/2] Fix macOS ScrollView resize and content inset behavior --- .../react-native/React/Base/RCTRootView.m | 7 ++ .../RCTSurfaceHostingView.mm | 9 +++ .../ScrollView/RCTEnhancedScrollView.h | 6 ++ .../ScrollView/RCTEnhancedScrollView.mm | 74 ++++++++++++++++++- .../ScrollView/RCTScrollViewComponentView.h | 4 + .../ScrollView/RCTScrollViewComponentView.mm | 63 +++++++++++++++- .../View/RCTViewComponentView.mm | 3 +- .../React/Views/ScrollView/RCTScrollView.m | 25 +++++++ 8 files changed, 185 insertions(+), 6 deletions(-) diff --git a/packages/react-native/React/Base/RCTRootView.m b/packages/react-native/React/Base/RCTRootView.m index 6f3cd9c37fce..038474b6bde3 100644 --- a/packages/react-native/React/Base/RCTRootView.m +++ b/packages/react-native/React/Base/RCTRootView.m @@ -206,6 +206,13 @@ - (BOOL)canBecomeFirstResponder #endif // macOS] } +#if TARGET_OS_OSX // [macOS +- (void)viewDidEndLiveResize { + [super viewDidEndLiveResize]; + [self setNeedsLayout]; +} +#endif // macOS] + - (void)setLoadingView:(RCTUIView *)loadingView // [macOS] { _loadingView = loadingView; diff --git a/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingView.mm b/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingView.mm index 89d491a2b9b9..1065ff97818d 100644 --- a/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingView.mm +++ b/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingView.mm @@ -144,6 +144,15 @@ - (void)disableActivityIndicatorAutoHide:(BOOL)disabled _autoHideDisabled = disabled; } +#pragma mark - NSView + +#if TARGET_OS_OSX // [macOS +- (void)viewDidEndLiveResize { + [super viewDidEndLiveResize]; + [self setNeedsLayout]; +} +#endif // macOS] + #pragma mark - isActivityIndicatorViewVisible - (void)setIsActivityIndicatorViewVisible:(BOOL)visible diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h index ea4037ede710..f344c5b2d389 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h @@ -52,6 +52,12 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign) BOOL snapToEnd; @property (nonatomic, copy) NSArray *snapToOffsets; +#if TARGET_OS_OSX // [macOS +- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated; +- (void)zoomToRect:(CGRect)rect animated:(BOOL)animated; +- (void)flashScrollIndicators; +#endif // macOS] + /* * Makes `setContentOffset:` method no-op when given `block` is executed. * The block is being executed synchronously. diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm index a7bc9c5b4425..8265716314e8 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm @@ -41,7 +41,12 @@ - (instancetype)initWithFrame:(CGRect)frame // because this attribute affects a position of vertical scrollbar; we don't want this // scrollbar flip because we also flip it with whole `UIScrollView` flip. self.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight; -#endif // [macOS] +#else // [macOS + self.automaticallyAdjustsContentInsets = NO; + self.hasHorizontalScroller = YES; + self.hasVerticalScroller = YES; + self.autohidesScrollers = YES; +#endif // macOS] __weak __typeof(self) weakSelf = self; _delegateSplitter = [[RCTGenericDelegateSplitter alloc] initWithDelegateUpdateBlock:^(id delegate) { @@ -98,16 +103,83 @@ - (void)setContentOffset:(CGPoint)contentOffset if (_isSetContentOffsetDisabled) { return; } +#if !TARGET_OS_OSX // [macOS] super.contentOffset = CGPointMake( RCTSanitizeNaNValue(contentOffset.x, @"scrollView.contentOffset.x"), RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y")); +#else // [macOS + if (!NSEqualPoints(contentOffset, self.documentVisibleRect.origin)) { + [self.contentView scrollToPoint:contentOffset]; + [self reflectScrolledClipView:self.contentView]; + } +#endif // macOS] } - (void)setFrame:(CGRect)frame { +#if !TARGET_OS_OSX // [macOS] [super setFrame:frame]; [self centerContentIfNeeded]; +#else // [macOS + // Preserving and revalidating `contentOffset`. + CGPoint originalOffset = self.contentOffset; + + [super setFrame:frame]; + + UIEdgeInsets contentInset = self.contentInset; + CGSize contentSize = self.contentSize; + + // If contentSize has not been measured yet we can't check bounds. + if (CGSizeEqualToSize(contentSize, CGSizeZero)) { + self.contentOffset = originalOffset; + } else { + CGSize boundsSize = self.bounds.size; + CGFloat xMaxOffset = contentSize.width - boundsSize.width + contentInset.right; + CGFloat yMaxOffset = contentSize.height - boundsSize.height + contentInset.bottom; + // Make sure offset doesn't exceed bounds. This can happen on screen rotation. + if ((originalOffset.x >= -contentInset.left) && (originalOffset.x <= xMaxOffset) && + (originalOffset.y >= -contentInset.top) && (originalOffset.y <= yMaxOffset)) { + return; + } + self.contentOffset = CGPointMake( + MAX(-contentInset.left, MIN(xMaxOffset, originalOffset.x)), + MAX(-contentInset.top, MIN(yMaxOffset, originalOffset.y))); + } +#endif // macOS] +} + +#if TARGET_OS_OSX // [macOS +- (NSSize)contentSize +{ + if (!self.documentView) { + return [super contentSize]; + } + + return self.documentView.frame.size; +} + +- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated +{ + if (animated) { + [NSAnimationContext beginGrouping]; + [[NSAnimationContext currentContext] setDuration:0.3]; + [[self.contentView animator] setBoundsOrigin:contentOffset]; + [NSAnimationContext endGrouping]; + } else { + self.contentOffset = contentOffset; + } +} + +- (void)zoomToRect:(CGRect)rect animated:(BOOL)animated +{ + [self magnifyToFitRect:rect]; +} + +- (void)flashScrollIndicators +{ + [self flashScrollers]; } +#endif // macOS] - (void)didAddSubview:(RCTPlatformView *)subview // [macOS] { diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.h index 7b5616d075c1..4e0d4d6291f1 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.h @@ -54,6 +54,10 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong, readonly) RCTGenericDelegateSplitter> *scrollViewDelegateSplitter; +#if TARGET_OS_OSX // [macOS +@property (nonatomic, assign) UIEdgeInsets contentInset; +#endif // macOS] + @end /* 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 57746520d786..4ffbc2c2b0db 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -151,7 +151,7 @@ - (instancetype)initWithFrame:(CGRect)frame _containerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; [_scrollView setDocumentView:_containerView]; #endif // macOS] - + #if !TARGET_OS_OSX // [macOS] [self.scrollViewDelegateSplitter addDelegate:self]; #endif // [macOS] @@ -279,6 +279,19 @@ static inline UIViewAnimationOptions animationOptionsWithCurve(UIViewAnimationCu } #endif +#if TARGET_OS_OSX // [macOS +- (void)setContentInset:(UIEdgeInsets)contentInset +{ + if (UIEdgeInsetsEqualToEdgeInsets(contentInset, _contentInset)) { + return; + } + + _contentInset = contentInset; + _scrollView.contentInset = contentInset; + _scrollView.scrollIndicatorInsets = contentInset; +} +#endif // macOS] + - (RCTGenericDelegateSplitter> *)scrollViewDelegateSplitter { return ((RCTEnhancedScrollView *)_scrollView).delegateSplitter; @@ -408,7 +421,11 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & MAP_SCROLL_VIEW_PROP(zoomScale); if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset) { +#if !TARGET_OS_OSX // [macOS] _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset); +#else // [macOS + self.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset); +#endif // macOS] } RCTEnhancedScrollView *scrollView = (RCTEnhancedScrollView *)_scrollView; @@ -452,7 +469,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _shouldUpdateContentInsetAdjustmentBehavior = NO; } #endif // [macOS] - + MAP_SCROLL_VIEW_PROP(disableIntervalMomentum); MAP_SCROLL_VIEW_PROP(snapToInterval); @@ -673,6 +690,22 @@ - (void)prepareForRecycle _firstVisibleView = nil; } +#if TARGET_OS_OSX // [macOS +#pragma mark - NSScrollView scroll notification + +- (void)scrollViewDocumentViewBoundsDidChange:(__unused NSNotification *)notification +{ + RCTEnhancedScrollView *scrollView = _scrollView; + + if (scrollView.centerContent) { + // Update content centering through contentOffset setter + [scrollView setContentOffset:scrollView.contentOffset]; + } + + [self scrollViewDidScroll:scrollView]; +} +#endif // macOS] + #pragma mark - UIScrollViewDelegate #if !TARGET_OS_OSX // [macOS] @@ -812,9 +845,25 @@ - (void)didMoveToWindow [super didMoveToWindow]; #else // [macOS - (void)viewDidMoveToWindow // [macOS] +#endif // [macOS] { [super viewDidMoveToWindow]; -#endif // [macOS] + +#if TARGET_OS_OSX // [macOS + NSNotificationCenter *defaultCenter = [NSNotificationCenter defaultCenter]; + if (self.window == nil) { + // Unregister scrollview's clipview bounds change notifications + [defaultCenter removeObserver:self + name:NSViewBoundsDidChangeNotification + object:_scrollView.contentView]; + } 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 + } +#endif // macOS] if (!self.window) { // The view is being removed, ensure that the scroll end event is dispatched @@ -898,6 +947,8 @@ - (void)flashScrollIndicators { #if !TARGET_OS_OSX // [macOS] [_scrollView flashScrollIndicators]; +#else // [macOS + [(RCTEnhancedScrollView *)_scrollView flashScrollers]; #endif // [macOS] } @@ -997,7 +1048,11 @@ - (void)scrollToOffset:(CGPoint)offset animated:(BOOL)animated [self _forceDispatchNextScrollEvent]; +#if !TARGET_OS_OSX // [macOS] [_scrollView setContentOffset:offset animated:animated]; +#else // [macOS + [(RCTEnhancedScrollView *)_scrollView setContentOffset:offset animated:animated]; +#endif // macOS] if (!animated) { // When not animated, the expected workflow in ``scrollViewDidEndScrollingAnimation`` after scrolling is not going @@ -1010,6 +1065,8 @@ - (void)zoomToRect:(CGRect)rect animated:(BOOL)animated { #if !TARGET_OS_OSX // [macOS] [_scrollView zoomToRect:rect animated:animated]; +#else // [macOS + [(RCTEnhancedScrollView *)_scrollView zoomToRect:rect animated:animated]; #endif // [macOS] } 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 78b616a03a7a..d77ba61d8012 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -167,7 +167,7 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection [self invalidateLayer]; } } -#else // [macOS SAAD +#else // [macOS - (void)viewDidChangeEffectiveAppearance { [super viewDidChangeEffectiveAppearance]; @@ -1707,7 +1707,6 @@ - (BOOL)didActivateAccessibilityCustomAction:(UIAccessibilityCustomAction *)acti } #if TARGET_OS_OSX // [macOS - - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args { if ([commandName isEqualToString:@"focus"]) { diff --git a/packages/react-native/React/Views/ScrollView/RCTScrollView.m b/packages/react-native/React/Views/ScrollView/RCTScrollView.m index afd67aea8c8e..bbf95176f839 100644 --- a/packages/react-native/React/Views/ScrollView/RCTScrollView.m +++ b/packages/react-native/React/Views/ScrollView/RCTScrollView.m @@ -328,6 +328,7 @@ @implementation RCTScrollView { BOOL _allowNextScrollNoMatterWhat; #if TARGET_OS_OSX // [macOS BOOL _notifyDidScroll; + BOOL _disableScrollEvents; NSPoint _lastScrollPosition; #endif // macOS] CGRect _lastClippedToRect; @@ -570,8 +571,28 @@ - (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews - (void)setFrame:(CGRect)frame { +#if !TARGET_OS_OSX // [macOS] + [super setFrame:frame]; +#else // [macOS + /** + * Setting the frame on the scroll view will randomly generate between 0 and 4 scroll events. These events happen + * during the layout phase of the view which generates layout notifications that are sent through the bridge. + * Because the bridge is heavily used, the scroll events are throttled and reach the JS thread with a random delay. + * Because the scroll event stores the clip and content view size, delayed scroll events will submit stale layout + * information that can break virtual list implemenations. + * By disabling scroll events during the execution of the setFrame method and scheduling one notification on + * the next run loop, we can mitigate the delayed scroll event by sending it at a time where the bridge is not busy. + */ + _disableScrollEvents = YES; [super setFrame:frame]; + _disableScrollEvents = NO; + + if (self.window != nil && !self.window.inLiveResize) { + [self performSelector:@selector(scrollViewDocumentViewBoundsDidChange:) withObject:nil afterDelay:0]; + } +#endif // macOS] [self centerContentIfNeeded]; + } - (void)insertReactSubview:(RCTPlatformView *)view atIndex:(NSInteger)atIndex // [macOS] @@ -867,6 +888,10 @@ - (void)flashScrollIndicators #if TARGET_OS_OSX // [macOS - (void)scrollViewDocumentViewBoundsDidChange:(__unused NSNotification *)notification { + if (_disableScrollEvents) { + return; + } + if (_scrollView.centerContent) { // contentOffset setter dynamically centers content when _centerContent == YES [_scrollView setContentOffset:_scrollView.contentOffset]; From c3053bccff5d22d677eefe2678d80ee9abcc6b54 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 26 Mar 2026 13:45:42 -0500 Subject: [PATCH 2/2] Fix iOS build for ScrollView component view --- .../ScrollView/RCTScrollViewComponentView.mm | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 29fbcf62b3e1..8c309adba96c 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -95,6 +95,15 @@ @interface RCTScrollViewComponentView () < RCTScrollableProtocol, RCTEnhancedScrollViewOverridingDelegate> +- (void)_preserveContentOffsetIfNeededWithBlock:(void (^)())block; +- (void)_remountChildrenIfNeeded; +- (void)_remountChildren; +- (void)_forceDispatchNextScrollEvent; +- (void)_handleScrollEndIfNeeded; +- (void)_handleFinishedScrolling:(RCTUIScrollView *)scrollView; +- (void)_prepareForMaintainVisibleScrollPosition; +- (void)_adjustForMaintainVisibleContentPosition; + @end @implementation RCTScrollViewComponentView { @@ -845,9 +854,9 @@ - (void)didMoveToWindow [super didMoveToWindow]; #else // [macOS - (void)viewDidMoveToWindow // [macOS] -#endif // [macOS] { [super viewDidMoveToWindow]; +#endif // [macOS] #if TARGET_OS_OSX // [macOS NSNotificationCenter *defaultCenter = [NSNotificationCenter defaultCenter];