diff --git a/patches/react-native@0.86.0.patch b/patches/react-native@0.86.0.patch index 981a4c5c0e..693c68a6e3 100644 --- a/patches/react-native@0.86.0.patch +++ b/patches/react-native@0.86.0.patch @@ -197,3 +197,106 @@ index ac553045a9c0ce77e288277912538d9e131ebc01..d99c8f4db5a07f1e4ffe7e03ff23adce NSRange visibleGlyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; +diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm +index 60160efb163d91813fa2ca7ca758b51afcf261e1..4791a4f123f74d9737e2d0cb1b7fa6e129315562 100644 +--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm ++++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm +@@ -39,8 +39,45 @@ - (void)setRefreshing:(BOOL)refreshing + + #else + ++// Fix for https://github.com/facebook/react-native/issues/43388 — on iOS 17.4+ ++// the pull-to-refresh trigger haptic is permanently suppressed if tintColor is ++// set before the control is actually inside the scroll view's view hierarchy. ++// Assigning scrollView.refreshControl does NOT insert it immediately (UIKit ++// does that lazily on a later layout pass), so attach-time is still too early. ++// Instead, store the wanted color and apply it from didMoveToSuperview, and ++// refuse direct tintColor sets until the superview is the scroll view. ++// Mirrors Bluesky's Paper patch (bluesky-social/social-app#5605). ++@interface RCTHapticCompatibleRefreshControl : UIRefreshControl ++@property (nonatomic, strong) UIColor *customTintColor; ++@end ++ ++@implementation RCTHapticCompatibleRefreshControl ++ ++- (void)didMoveToSuperview ++{ ++ [super didMoveToSuperview]; ++ if (self.customTintColor) { ++ [self setTintColor:self.customTintColor]; ++ } ++} ++ ++- (void)setCustomTintColor:(UIColor *)customTintColor ++{ ++ _customTintColor = customTintColor; ++ [self setTintColor:customTintColor]; ++} ++ ++- (void)setTintColor:(UIColor *)tintColor ++{ ++ if ([self.superview isKindOfClass:[UIScrollView class]] && self.tintColor != tintColor) { ++ [super setTintColor:tintColor]; ++ } ++} ++ ++@end ++ + @implementation RCTPullToRefreshViewComponentView { +- UIRefreshControl *_refreshControl; ++ RCTHapticCompatibleRefreshControl *_refreshControl; + RCTScrollViewComponentView *__weak _scrollViewComponentView; + // This variable keeps track of whether the view is recycled or not. Once the view is recycled, the component + // creates a new instance of UIRefreshControl, resetting the native props to the default values. +@@ -66,7 +103,7 @@ - (instancetype)initWithFrame:(CGRect)frame + + - (void)_initializeUIRefreshControl + { +- _refreshControl = [UIRefreshControl new]; ++ _refreshControl = [RCTHapticCompatibleRefreshControl new]; + [_refreshControl addTarget:self + action:@selector(handleUIControlEventValueChanged) + forControlEvents:UIControlEventValueChanged]; +@@ -79,6 +116,17 @@ + (ComponentDescriptorProvider)componentDescriptorProvider + return concreteComponentDescriptorProvider(); + } + ++// Recycled instances get all props force-applied in updateProps, which runs ++// before the new UIRefreshControl is inserted into the scroll view hierarchy; ++// touching the control that early suppresses the pull-to-refresh haptic on ++// iOS 17.4+ (react-native#43388). Opting out of recycling keeps every mount on ++// the untouched-before-attach path. Refresh controls are rare and cheap, so ++// losing recycling for them is negligible. +++ (BOOL)shouldBeRecycled ++{ ++ return NO; ++} ++ + - (void)prepareForRecycle + { + [super prepareForRecycle]; +@@ -93,7 +141,9 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & + const auto &newConcreteProps = static_cast(*props); + + if (_recycled || newConcreteProps.tintColor != oldConcreteProps.tintColor) { +- _refreshControl.tintColor = RCTUIColorFromSharedColor(newConcreteProps.tintColor); ++ // Goes through RCTHapticCompatibleRefreshControl, which defers the real ++ // tintColor set until the control is inside the scroll view (#43388). ++ _refreshControl.customTintColor = RCTUIColorFromSharedColor(newConcreteProps.tintColor); + } + + if (_recycled || newConcreteProps.progressViewOffset != oldConcreteProps.progressViewOffset) { +@@ -153,7 +203,12 @@ - (void)_updateTitle + const auto &concreteProps = static_cast(*_props); + + if (concreteProps.title.empty()) { +- _refreshControl.attributedTitle = nil; ++ // Avoid touching the control when there is nothing to clear — writing ++ // attributedTitle (even nil) before the control is in the scroll view ++ // hierarchy can suppress the pull-to-refresh haptic (#43388). ++ if (_refreshControl.attributedTitle != nil) { ++ _refreshControl.attributedTitle = nil; ++ } + return; + } + diff --git a/patches/react-native@0.86.0.patch.md b/patches/react-native@0.86.0.patch.md index ea90be648c..cb9055f519 100644 --- a/patches/react-native@0.86.0.patch.md +++ b/patches/react-native@0.86.0.patch.md @@ -6,6 +6,28 @@ Patching `RCTRefreshControl.mm` temporarily to play an impact haptic on refresh 17.4, there has been a regression somewhere causing haptics to not play on iOS on refresh. Should monitor for an update in the RN repo: https://github.com/facebook/react-native/issues/43388 +## RCTPullToRefreshViewComponentView.mm Patch - Same iOS 17.4+ haptic regression on New Arch + +The Paper fix above does not cover Fabric: `RCTPullToRefreshViewComponentView` owns a plain +`UIRefreshControl` and applies `tintColor` in `updateProps`, which runs during the Create mount +mutation - before `_attach` puts the control on the scroll view - so the haptic regression +(react-native#43388) resurfaced on the New Architecture. + +Port of the Paper approach to Fabric (developed and A/B tested in the `fresh-expo57-refresh` +repro app against bare RN 0.86.2 and Expo 54/RN 0.81.5 controls): + +- `RCTHapticCompatibleRefreshControl` subclass stores the wanted color in `customTintColor` and + refuses direct `setTintColor:` until the superview is the scroll view; `didMoveToSuperview` + applies the stored color once actually inside the hierarchy. +- `shouldBeRecycled = NO`: recycled instances get all props force-applied in `updateProps` before + the new control is inserted into the scroll view, which would re-trigger the bug; opting out of + recycling keeps every mount on the untouched-before-attach path. +- `_updateTitle` no longer writes `attributedTitle = nil` when there is nothing to clear - even a + nil write before attach suppresses the haptic. + +Upstream issue still open as of Aug 2026. Haptics cannot be verified on the simulator - physical +device only. + ## RCTEnhancedScrollView.mm / RCTScrollViewComponentView.mm Patch - centerContent insets stale after content resize on New Arch **TODO: Remove after bumping React Native to 0.87+** (fixed upstream by facebook/react-native#56832,