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:
- Parse the incoming link into a typed route intent.
- Store that intent outside the visible SwiftUI path.
- Gate route replay on auth readiness and bootstrap completion.
- Resume from one navigation owner only.
- 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.