patches/[email protected]
Patching RCTRefreshControl.mm temporarily to play an impact haptic on refresh when using iOS 17.4 or higher. Since
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
Both bugs share one root cause, established by instrumented frame-logging runs on the iOS 26
simulator (Aug 2026): writes to a detached UIRefreshControl are hazardous, because the
control's _UIRefreshControlModernContentView bakes in the state it observes at its own
creation. Facts proven by the logs:
scrollView.refreshControl assignment inserts the control and creates its content view
synchronously on iOS 26 (the "UIKit inserts lazily on a later layout pass" folklore is
false there).tintColor) while the control is still detached.bounds.origin exists at its creation and keeps
that y forever - width tracks on later layouts, y never re-pins.Consequences:
1. progressViewOffset. Stock Fabric writes the offset as a bounds.origin shift in
updateProps, pre-attach. The content view is then created (at insertion) already inside the
shifted bounds, pins to it, and cancels the shift exactly - spinner hidden behind the floating
home header (home is the only screen passing a non-zero offset). Stock RN appeared to work only
by accident: its own pre-attach tintColor write materialized the content view at origin 0
before the offset write. Possibly related upstream: react-native#54183.
2. Haptic (react-native#43388). The Paper fix above does not cover Fabric: updateProps
writes tintColor pre-attach, and a tint write on a detached control materializes the content
view outside the scroll view, permanently suppressing the trigger haptic on iOS 17.4+ (the
creation-time-state story likely explains this too, though the haptic wiring itself is not
observable in logs).
The fix: both tintColor and progressViewOffset are parked in the component view
(_pendingTintColor / _pendingProgressViewOffset, no UIRefreshControl subclass) and applied
only once _refreshControl.superview is the scroll view - by then the content view exists,
was created at origin 0, and a bounds shift lands visibly. Application points: immediately in
_updateX for runtime changes while attached; in _attach right after the assignment (insertion
is synchronous); and from layoutSubviews with a setNeedsLayout re-arm as a fallback should
insertion ever be deferred.
Supporting changes:
shouldBeRecycled = NO: recycled instances get all props force-applied in updateProps before
the new control is attached, which would re-trigger the pre-attach hazards; opting out 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.History: an earlier iteration fixed the offset by porting Paper's frame-offset trick into an
RCTHapticCompatibleRefreshControl subclass (worked, verified on device) - replaced by the
deferral once the root cause was understood. The control's didMoveToSuperview appeared broken
as a tint application point in early non-rigorous testing; unproven, not disproven.
Upstream issue #43388 still open as of Aug 2026. Haptics cannot be verified on the simulator - physical device only. Spinner position verified via frame logs; haptic on this variant NOT yet device-verified.
Opened issue in RN repo: https://github.com/react/react-native/issues/57843
TODO: Remove after bumping React Native to 0.87+ (fixed upstream by facebook/react-native#56832, commit d50c1b5207; first shipped in 0.87.0-rc.0).
On Fabric, centerContent centers by computing contentInset in centerContentIfNeeded, but that
recompute only ran on setFrame/didAddSubview/scrollViewDidZoom - not when a state update assigns a
new contentSize in updateState. Any content that resizes after mount inside a centerContent
ScrollView (e.g. the lightbox image crop view getting its real aspect ratio from onLoad when the embed
has no aspectRatio metadata) keeps the old insets: content rests off-center and the excess inset creates
phantom scroll range, so the image can be dragged and parked off-center and the native scroll steals the
swipe-down-to-dismiss pan. The old architecture paired every contentSize update with re-centering in
RCTScrollView.updateContentSizeIfNeeded; Fabric dropped that link.
Backport of the upstream fix: setContentSize:/setCenterContent: overrides on RCTEnhancedScrollView
that call centerContentIfNeeded, plus the updateProps guards so the contentInset prop does not
fight the computed centering inset.
Issue: https://github.com/facebook/react-native/issues/55090
TODO: Remove after bumping React Native to 0.87+ (fixed upstream by facebook/react-native#56747, commit efcab20908; first shipped in 0.87.0-rc.0).
On Fabric, betterHitTest in RCTScrollViewComponentView deliberately skips the _containerView
and hit-tests its grandchildren, returning self (the wrapper component view) when the touch lands
inside the scroll view bounds but outside any content. UIKit only delivers touches to a gesture
recognizer when the hit view is the recognizer's view or a descendant of it, and the UIScrollView
is a child of the wrapper - so its native pinch/pan recognizers never see those touches. In the
lightbox this means pinch-to-zoom and pan-while-zoomed only respond when the fingers are over the
image itself, not over the letterbox bars above/below it. On the old architecture, default UIKit
hit-testing returns the UIScrollView itself for those touches, so everything works.
Backport of the upstream one-liner: return _scrollView instead of self so touches in the
content-less area are attributed to the UIScrollView.
Issue: https://github.com/facebook/react-native/issues/54123 PR: https://github.com/react/react-native/pull/56747
Fixes Sentry issue APP-T20Q: IllegalStateException: Required value was null thrown by
checkNotNull(allChildren?.get(idx)) in updateSubviewClipStatus, reached from
ReactScrollView.onScrollChanged -> updateClippingRect during an animated smooth scroll
(New Architecture, removeClippedSubviews).
The clipping loop in updateClippingToRect captures its bound once, but clipping a view
(removeViewsInLayout) can synchronously trigger reentrant child removal (layout-change
listeners, animation-end callbacks, Fabric mounting on the UI thread), which compacts
allChildren and nulls the tail mid-loop. Upstream already catches the
IndexOutOfBoundsException variant of this corruption with diagnostics, but the null-child
variant throws IllegalStateException and escapes as a fatal crash. A null entry means the
view is already detached, so we skip it and count it as clipped to keep index math aligned.
Not fixed upstream as of July 2026 (identical checkNotNull on main); the sibling fix
attempt facebook/react-native#57365 for the same bookkeeping corruption (different stack)
was abandoned. Re-check when bumping React Native.
Note on build modes: production Android builds compile react-android from source
(buildReactNativeFromSource: IS_PRODUCTION via expo-build-properties in app.config.js
injects the includeBuild/dependency-substitution block at prebuild), so this hunk IS
active in production releases. Local dev builds prebuilt in a non-production env consume
the prebuilt AAR from Maven Central instead, where this hunk (like any ReactAndroid
source change) has no effect - do not expect to see the fix in a local debug build unless
you prebuild with EXPO_PUBLIC_ENV=production or add the substitution block manually.
TODO: Remove after bumping React Native to a release that contains facebook/react-native#57483
(commit 918fb15bfe5f, on main; not in 0.86 and not yet released).
Backport of the upstream one-liner: use a real ternary so the numeric weight is returned instead of
the boolean. For a double, (A != 0.0) ? A : B is exactly equivalent to the original A ?: B.
PR: https://github.com/facebook/react-native/pull/57483
Issue: https://github.com/react/react-native/issues/53450#issuecomment-3298157830 Bandaid fix taken from: https://github.com/react/react-native/commit/581d643a9e59fd88f93757f80194e1efd11bd0e5