DEV Community

Todd Sullivan
Todd Sullivan

Posted on

The iOS 26 Deep-Link Bug That Only Happened When the App Was Already Open

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:

  1. app not running
  2. tap myapp://auth/callback?code=...
  3. iOS launches the app
  4. Expo Router mounts auth/callback
  5. PKCE code exchange succeeds

Warm start failed:

  1. app already open
  2. tap the same magic link
  3. app comes foreground
  4. no JS Linking event
  5. 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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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: [:])
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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)