What the problem actually is

A user opens a deep link to a specific screen.

The app requires authentication first.

The login screen appears, auth succeeds, and then one of three bad things happens:

  • the app drops the user on the home screen
  • the app pushes the wrong stack
  • the app partially restores old state and corrupts the intended path

This usually gets labeled as a “SwiftUI navigation bug”.

It is not.

It is a route ownership bug caused by mixing deep-link intent, visible navigation state, and auth transitions into the same mutable state.

Why it keeps happening

Teams often decode the deep link and immediately try to navigate.

That works when the session is already valid.

It breaks when authentication, launch restoration, or async bootstrap gets in the middle.

Then the app has no stable answer to a simple question:

What route did the user actually intend to reach?

If that intent is stored only inside the visible navigation path, auth can wipe it out. If it is stored in many places, multiple flows compete to resume it.

The implementation boundary that matters

The important boundary is the difference between:

  • route intent
  • visible navigation state

Route intent is durable. It means “after the app is ready, take the user here.”

Visible navigation state is temporary. It means “this is what the app can show right now.”

Once those two concepts are separated, the flow becomes easier to reason about.

The deep link should produce a durable intent.

The navigation owner should decide when the app is ready to materialize that intent into a path.

A concrete pattern to fix it

The pattern that holds up well is:

  1. Parse the incoming link into a typed route intent.
  2. Store that intent outside the visible SwiftUI path.
  3. Gate route replay on auth readiness and bootstrap completion.
  4. Resume from one navigation owner only.
  5. Clear the intent only after successful path application.

This is simplified pseudocode, not production code.

import Foundation

enum AppRoute: Equatable {
    case article(id: String)
    case accountSettings
    case paywall(source: String)
}

final class NavigationCoordinator: ObservableObject {
    @Published var path: [AppRoute] = []

    private var pendingIntent: AppRoute?
    private var isAuthenticated = false
    private var bootstrapFinished = false

    func handleIncomingRoute(_ route: AppRoute) {
        pendingIntent = route
        tryResumePendingIntent()
    }

    func authDidComplete() {
        isAuthenticated = true
        tryResumePendingIntent()
    }

    func bootstrapDidFinish() {
        bootstrapFinished = true
        tryResumePendingIntent()
    }

    private func tryResumePendingIntent() {
        guard isAuthenticated, bootstrapFinished else { return }
        guard let pendingIntent else { return }

        path = materializePath(for: pendingIntent)
        self.pendingIntent = nil
    }

    private func materializePath(for route: AppRoute) -> [AppRoute] {
        [route]
    }
}

This makes the route durable without letting every lifecycle event mutate the actual path directly.

How to verify the fix

This flow needs more than one happy-path test.

Verify all of these:

  • deep link while already authenticated
  • deep link on cold start with expired session
  • deep link while the login screen is already visible
  • deep link after the app was background-killed during auth
  • deep link arriving while restoration is reconstructing a previous stack

Watch for:

  • whether the final screen is correct
  • whether the navigation stack is duplicated
  • whether the app replays stale state before the intended route
  • whether pending intent gets cleared only after successful resume

What still goes wrong in production

The first common bug is multiple navigation owners. A root coordinator, a feature view model, and a login flow each push their own route updates.

The second is resuming too early. Auth might be done, but user profile, feature flags, or restoration are not.

The third is never expiring stale intent. If the user abandons login and later completes a different action, an old pending route can suddenly replay.

The safe contract is simple:

Deep links create intent. One owner resumes intent. Visible navigation state is only the result of that decision.

That is what makes deep links survive auth and restoration without corrupting the route.