diff --git a/patches/@bsky.app__expo-scroll-edge-effect.patch b/patches/@bsky.app__expo-scroll-edge-effect.patch new file mode 100644 index 0000000000..31189cd6a7 --- /dev/null +++ b/patches/@bsky.app__expo-scroll-edge-effect.patch @@ -0,0 +1,88 @@ +diff --git a/ios/ExpoScrollEdgeEffectView.swift b/ios/ExpoScrollEdgeEffectView.swift +index 44b03cc7c33e770585e232635b7db2dfd21aee8b..6b24e55cf97c96142e53172dc9097c4e054d4d53 100644 +--- a/ios/ExpoScrollEdgeEffectView.swift ++++ b/ios/ExpoScrollEdgeEffectView.swift +@@ -4,10 +4,14 @@ import UIKit + class ExpoScrollEdgeEffectView: ExpoView { + private var currentInteraction: NSObject? + ++ private var isAttached = false ++ private var resolveAttempts = 0 ++ private let maxResolveAttempts = 30 ++ + var scrollViewTag: Int? { + didSet { + if scrollViewTag != oldValue { +- updateInteraction() ++ scheduleResolve() + } + } + } +@@ -15,7 +19,7 @@ class ExpoScrollEdgeEffectView: ExpoView { + var edge: String = "top" { + didSet { + if edge != oldValue { +- updateInteraction() ++ scheduleResolve() + } + } + } +@@ -23,7 +27,7 @@ class ExpoScrollEdgeEffectView: ExpoView { + var effect: String = "automatic" { + didSet { + if effect != oldValue { +- updateInteraction() ++ scheduleResolve() + } + } + } +@@ -42,17 +46,39 @@ class ExpoScrollEdgeEffectView: ExpoView { + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { ++ scheduleResolve() ++ } ++ } ++ ++ override func layoutSubviews() { ++ super.layoutSubviews() ++ ++ if !isAttached, window != nil, scrollViewTag != nil, resolveAttempts < maxResolveAttempts { + updateInteraction() + } + } + ++ private func scheduleResolve() { ++ resolveAttempts = 0 ++ updateInteraction() ++ } ++ + private func updateInteraction() { + removeInteraction() + + guard window != nil else { return } + guard let tag = scrollViewTag else { return } + guard #available(iOS 26, *) else { return } +- guard let scrollView = resolveScrollView(tag: tag) else { return } ++ guard let scrollView = resolveScrollView(tag: tag) else { ++ if resolveAttempts < maxResolveAttempts { ++ resolveAttempts += 1 ++ setNeedsLayout() ++ } ++ return ++ } ++ ++ resolveAttempts = 0 ++ isAttached = true + + let resolvedEdge = self.resolveEdge(edge) + let resolvedStyle = self.resolveEffectStyle(effect) +@@ -79,6 +105,8 @@ class ExpoScrollEdgeEffectView: ExpoView { + } + + private func removeInteraction() { ++ isAttached = false ++ + guard #available(iOS 26, *) else { return } + + if let interaction = currentInteraction as? UIScrollEdgeElementContainerInteraction { diff --git a/patches/@bsky.app__expo-scroll-edge-effect.patch.md b/patches/@bsky.app__expo-scroll-edge-effect.patch.md new file mode 100644 index 0000000000..1e7d5d6dce --- /dev/null +++ b/patches/@bsky.app__expo-scroll-edge-effect.patch.md @@ -0,0 +1,44 @@ +# @bsky.app\_\_expo-scroll-edge-effect.patch + +Fixes the iOS 26 scroll edge effect (the blur under the status bar) failing to +attach on the New Architecture (Fabric). + +## Why + +The native view resolves the target scroll view by React tag: +`scrollViewTag` (published from JS when the scroll view mounts) -> +`appContext.findView(withTag:)` -> attach a `UIScrollEdgeElementContainerInteraction`. + +On Paper this resolved on the first try, because view-by-tag registration was +serialized through the single UIManager batch pipeline - by the time the tag +prop reached native, the scroll view was already in the registry. + +On Fabric there is no synchronous tag->view registry. The scroll view's +`ComponentView` is registered when its mount transaction is applied, which is +deferred relative to the JS commit that set the tag. So the one-shot +`resolveScrollView` runs before the view is mounted and returns nil, the +interaction never attaches, and the blur is lost. (The un-gated case happened to +work only because the header's `didMoveToWindow` provided a late second attempt; +a list mounted later - e.g. behind an `isActive` gate - had no such retry.) + +## What it does + +Makes resolution event-driven and self-healing instead of one-shot: + +- When `resolveScrollView` returns nil, it bumps a bounded counter and calls + `setNeedsLayout()`; `layoutSubviews()` re-attempts as sibling views mount, so + it retries exactly when the scroll view actually appears - no fixed timer. +- `isAttached` stops re-running once resolved (no teardown/re-attach churn). +- `resolveAttempts` / `maxResolveAttempts` bound the retries so a + permanently-absent scroll view (list not rendered) cannot spin layout forever. +- The budget is reset on every "fresh" trigger (prop change, entering the + window) via `scheduleResolve()`, so each new tag gets a full retry budget. + +## Notes + +- iOS only; gated behind `#available(iOS 26, *)`. +- This mirrors how `react-native-screens` handles the same effect on Fabric + (resolve from the live view hierarchy and re-apply on mount/layout lifecycle + rather than a single tag lookup). A cleaner long-term fix would move + resolution to the scroll-view side, but this is the minimal change to the + existing tag-based design. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3524a97903..c201add825 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,7 @@ overrides: react-native-screens: 4.24.0 patchedDependencies: + '@bsky.app/expo-scroll-edge-effect': b745392cadb2a707a0b6fcd12de9dc3313765aaf6de089a4a69e6b910a19d724 '@sentry/react-native@6.20.0': 1d48e4d5178f5684eb33262299e7eed283bf9601f79b9ae1af63a7f607d3483a expo-age-range@0.2.18: c325225749993424461eeece1d97a99b19c00da2ebc958a73c12e4eca0c39306 expo-glass-effect@55.0.8: 6c9fa5b104e53b87df4e17b320acb3b94d12ac90c0101190045dd620681efff0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fe83a192a0..a5cff2de7c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,6 +19,7 @@ allowBuilds: 'esbuild': true 'unrs-resolver': true patchedDependencies: + '@bsky.app/expo-scroll-edge-effect': patches/@bsky.app__expo-scroll-edge-effect.patch '@sentry/react-native@6.20.0': patches/@sentry__react-native@6.20.0.patch expo-age-range@0.2.18: patches/expo-age-range@0.2.18.patch 'expo-haptics@15.0.8': patches/expo-haptics@15.0.8.patch