I spent a chunk of this week chasing a bug that looked like auth, then routing, then Supabase, then Expo Router.
Actual root cause: iOS 26 was receiving the magic link, but the running app never got the warm-start URL in JavaScript.
Cold start worked:
- app not running
- tap
myapp://auth/callback?code=... - iOS launches the app
- Expo Router mounts
auth/callback - PKCE code exchange succeeds
Warm start failed:
- app already open
- tap the same magic link
- app comes foreground
- no JS
Linkingevent - user is still logged out
No crash. No useful exception. Just a login flow that works from killed state and fails from running state, which is exactly the kind of mobile bug that eats a day.
The Native Part: SceneDelegate Still Matters
On iOS 26, warm-start URLs are delivered through the scene lifecycle:
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
for context in URLContexts {
forwardURL(context.url)
}
}
The Expo app already had an AppDelegate, but warm-start delivery needed a UIWindowSceneDelegate as well. The slightly non-obvious bit was how to forward the URL.
Calling RCTLinkingManager directly looked reasonable, but it only posts the React Native URL notification. expo-linking was not listening to that path in this app. Routing through AppDelegate.application(_:open:options:) let ExpoAppDelegate forward the URL to all of its subscribers, including Expo's linking module:
private func forwardURL(_ url: URL) {
let app = UIApplication.shared
if let appDelegate = app.delegate as? AppDelegate {
_ = appDelegate.application(app, open: url, options: [:])
} else {
RCTLinkingManager.application(app, open: url, options: [:])
}
}
That got the URL into JS.
Then iOS 26 added one more trap.
The Key Window Trap
The system log had the clue: key window is null.
The app creates its UIWindow in didFinishLaunchingWithOptions, before the UIWindowScene exists. startReactNative calls makeKeyAndVisible(), but at that point the window is not attached to a scene yet.
On iOS 26, that meant scene:openURLContexts: never fired for warm-start links.
The fix was to associate the existing window with the scene and call makeKeyAndVisible() again after windowScene is set:
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
if let appDelegate = UIApplication.shared.delegate as? AppDelegate,
let window = appDelegate.window {
window.windowScene = windowScene
window.makeKeyAndVisible()
}
}
After that, scene:openURLContexts: fired reliably. Verified on the iOS 26 simulator: native SceneDelegate log, AppDelegate openURL log, then the JS linking handler.
The JS Part: Don't Route During Foreground Animation
Once the URL reached JS, the first instinct was to navigate to /auth/callback and let that screen do the exchange.
That was fragile for two reasons.
First, during warm start the app is still moving through UISceneActivationStateForegroundInactive. I saw the URL arrive at 10:56:09.749; foreground transition finished at 10:56:10.140. Navigation commands during that window can be silently dropped.
Second, auth/callback may already be mounted. A useEffect([]) on that screen will not re-run just because another link arrived.
So I moved warm-start exchange into the always-mounted root layout:
const linkSub = Linking.addEventListener("url", ({ url }) => {
const parsed = Linking.parse(url);
const code = typeof parsed.queryParams?.code === "string"
? parsed.queryParams.code
: null;
if (code) {
supabase.auth.exchangeCodeForSession(code).catch(() => {});
const unsub = useAuthStore.subscribe(({ session, signingIn }) => {
if (session && !signingIn) {
unsub();
router.replace("/");
}
});
setTimeout(unsub, 30_000);
}
});
The important part is the split:
- native code guarantees warm-start URLs reach Expo linking
- root layout handles every warm-start URL event
- auth callback remains as the cold-start/race fallback
- navigation waits until the auth store has a settled session
The Takeaway
Deep links are not one flow. They are at least two:
- cold start: launch app into a URL
- warm start: deliver URL into an already-running app
If you only test the cold path, your auth flow can look perfect while the common real-world case is broken.
For Expo + React Native apps on newer iOS versions, I now treat warm-start deep links as their own integration test: app open, link tapped, native URL delivery observed, JS Linking event observed, auth state settled, navigation after state settle.
That is more ceremony than a one-line Linking.addEventListener, but it is the difference between a demo login flow and a production one.
Source: Recent Expo / React Native auth work: iOS 26 warm-start deep-link fix, SceneDelegate URL forwarding, PKCE exchange moved to root layout.
Tags: ios, reactnative, devops, javascript
Top comments (0)