Loading...
Loading...
Use when debugging navigation not responding, unexpected pops, deep links showing wrong screen, state lost on tab switch or background, crashes in navigationDestination, or any SwiftUI navigation failure - systematic diagnostics with production crisis defense
npx skill4agent add charleswiltgen/axiom axiom-swiftui-nav-diagnavigationDestination// 1. Add NavigationPath logging
NavigationStack(path: $path) {
RootView()
.onChange(of: path.count) { oldCount, newCount in
print("📍 Path changed: \(oldCount) → \(newCount)")
// If this never fires, link isn't modifying path
// If it fires unexpectedly, something else modifies path
}
}
// 2. Check navigationDestination is visible
// Put temporary print in destination closure
.navigationDestination(for: Recipe.self) { recipe in
let _ = print("🔗 Destination for Recipe: \(recipe.name)")
RecipeDetail(recipe: recipe)
}
// If this never prints, destination isn't being evaluated
// 3. Check NavigationLink is inside NavigationStack
// Visual inspection: Trace from NavigationLink up view hierarchy
// Must hit NavigationStack, not another container first
// 4. Check path state location
// @State must be in stable view (not recreated each render)
// Must be @State, @StateObject, or @Observable — not local variable
// 5. Test basic case in isolation
// Create minimal reproduction
NavigationStack {
NavigationLink("Test", value: "test")
.navigationDestination(for: String.self) { str in
Text("Pushed: \(str)")
}
}
// If this works, problem is in your specific setup| Observation | Diagnosis | Next Step |
|---|---|---|
| onChange never fires on tap | NavigationLink not in NavigationStack hierarchy | Pattern 1a |
| onChange fires but view doesn't push | navigationDestination not found/loaded | Pattern 1b |
| onChange fires, view pushes, then immediate pop | View identity issue or path modification | Pattern 2a |
| Path changes unexpectedly (not from tap) | External code modifying path | Pattern 2b |
| Deep link path.append() doesn't navigate | Timing issue or wrong thread | Pattern 3b |
| State lost on tab switch | NavigationStack shared across tabs | Pattern 4a |
| Works first time, fails on return | View recreation issue | Pattern 5a |
Navigation problem?
├─ Navigation tap does nothing?
│ ├─ NavigationLink inside NavigationStack?
│ │ ├─ No → Pattern 1a (Link outside Stack)
│ │ └─ Yes → Check navigationDestination
│ │
│ ├─ navigationDestination registered?
│ │ ├─ Inside lazy container? → Pattern 1b (Lazy Loading)
│ │ ├─ Type mismatch? → Pattern 1c (Type Registration)
│ │ └─ Blocked by sheet/popover? → Pattern 1d (Modal Blocking)
│ │
│ └─ Using view-based link?
│ └─ → Pattern 1e (Deprecated API)
│
├─ Unexpected pop back?
│ ├─ Immediate pop after push?
│ │ ├─ View body recreating path? → Pattern 2a (Path Recreation)
│ │ ├─ @State in wrong view? → Pattern 2a (State Location)
│ │ └─ ForEach id changing? → Pattern 2c (Identity Change)
│ │
│ ├─ Pop when shouldn't?
│ │ ├─ External code calling removeLast? → Pattern 2b (Unexpected Modification)
│ │ ├─ Task cancelled? → Pattern 2b (Async Cancellation)
│ │ └─ MainActor issue? → Pattern 2d (Threading)
│ │
│ └─ Back button behavior wrong?
│ └─ → Pattern 2e (Stack Corruption)
│
├─ Deep link not working?
│ ├─ URL not received?
│ │ ├─ onOpenURL not called? → Check URL scheme in Info.plist
│ │ └─ Universal Links issue? → Check apple-app-site-association
│ │
│ ├─ URL received, path not updated?
│ │ ├─ path.append not on MainActor? → Pattern 3a (Threading)
│ │ ├─ Timing issue (app not ready)? → Pattern 3b (Initialization)
│ │ └─ NavigationStack not created yet? → Pattern 3b (Lifecycle)
│ │
│ └─ Path updated, wrong screen shown?
│ ├─ Wrong path order? → Pattern 3c (Path Construction)
│ ├─ Wrong type appended? → Pattern 3c (Type Mismatch)
│ └─ Item not found? → Pattern 3d (Data Resolution)
│
├─ State lost?
│ ├─ Lost on tab switch?
│ │ ├─ Shared NavigationStack? → Pattern 4a (Shared State)
│ │ └─ Tab recreation? → Pattern 4a (Tab Identity)
│ │
│ ├─ Lost on background/foreground?
│ │ ├─ No SceneStorage? → Pattern 4b (No Persistence)
│ │ └─ Decode failure? → Pattern 4c (Decode Error)
│ │
│ └─ Lost on rotation/size change?
│ └─ → Pattern 4d (Layout Recreation)
│
└─ Crash?
├─ EXC_BAD_ACCESS in navigation code?
│ └─ → Pattern 5a (Memory Issue)
│
├─ Fatal error: type not registered?
│ └─ → Pattern 5b (Missing Destination)
│
└─ Decode failure on restore?
└─ → Pattern 5c (Restoration Crash)// Check view hierarchy — NavigationLink must be INSIDE NavigationStack
// ❌ WRONG — Link outside stack
struct ContentView: View {
var body: some View {
VStack {
NavigationLink("Go", value: "test") // Outside stack!
NavigationStack {
Text("Root")
}
}
}
}
// Check: Add background color to NavigationStack
NavigationStack {
Color.red // If link is on red, it's inside
}// ✅ CORRECT — Link inside stack
struct ContentView: View {
var body: some View {
NavigationStack {
VStack {
NavigationLink("Go", value: "test") // Inside stack
Text("Root")
}
.navigationDestination(for: String.self) { str in
Text("Pushed: \(str)")
}
}
}
}// ❌ WRONG — Destination inside lazy container (may not be loaded)
ScrollView {
LazyVStack {
ForEach(items) { item in
NavigationLink(item.name, value: item)
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item) // May not be evaluated!
}
}
}
}// ✅ CORRECT — Destination outside lazy container
ScrollView {
LazyVStack {
ForEach(items) { item in
NavigationLink(item.name, value: item)
}
}
}
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item) // Always available
}// Check: Value type must EXACTLY match destination type
// Link uses Recipe
NavigationLink(recipe.name, value: recipe) // value is Recipe
// Destination registered for... Recipe.ID?
.navigationDestination(for: Recipe.ID.self) { id in // ❌ Wrong type!
RecipeDetail(id: id)
}// Match types exactly
NavigationLink(recipe.name, value: recipe) // Recipe
.navigationDestination(for: Recipe.self) { recipe in // ✅ Recipe
RecipeDetail(recipe: recipe)
}
// OR change link to use ID
NavigationLink(recipe.name, value: recipe.id) // Recipe.ID
.navigationDestination(for: Recipe.ID.self) { id in // ✅ Recipe.ID
RecipeDetail(id: id)
}print(type(of: value))// ❌ WRONG — Path created in view body (reset every render)
struct ContentView: View {
var body: some View {
let path = NavigationPath() // Recreated every time!
NavigationStack(path: .constant(path)) {
// ...
}
}
}
// ❌ WRONG — @State in child view that gets recreated
struct ParentView: View {
@State var showChild = true
var body: some View {
if showChild {
ChildView() // Recreated when showChild toggles
}
}
}
struct ChildView: View {
@State var path = NavigationPath() // Lost when ChildView recreated
// ...
}// ✅ CORRECT — @State at stable level
struct ContentView: View {
@State private var path = NavigationPath() // Persists across renders
var body: some View {
NavigationStack(path: $path) {
RootView()
}
}
}
// ✅ CORRECT — @StateObject for ObservableObject
struct ContentView: View {
@StateObject private var navModel = NavigationModel()
var body: some View {
NavigationStack(path: $navModel.path) {
RootView()
}
}
}// ❌ WRONG — Modifying path from background task
func loadAndNavigate() async {
let recipe = await fetchRecipe()
path.append(recipe) // ⚠️ Not on MainActor!
}
// Check: Search for path.append, path.removeLast outside @MainActor context// ✅ CORRECT — Ensure MainActor
@MainActor
func loadAndNavigate() async {
let recipe = await fetchRecipe()
path.append(recipe) // ✅ MainActor isolated
}
// OR explicitly dispatch
func loadAndNavigate() async {
let recipe = await fetchRecipe()
await MainActor.run {
path.append(recipe)
}
}
// ✅ BEST — Use @Observable with @MainActor
@Observable
@MainActor
class Router {
var path = NavigationPath()
func navigate(to value: any Hashable) {
path.append(value)
}
}// ❌ WRONG — May be called before NavigationStack exists
.onOpenURL { url in
handleDeepLink(url) // NavigationStack may not be rendered yet
}
func handleDeepLink(_ url: URL) {
path.append(parsedValue) // Modifies path that doesn't exist yet
}// ✅ CORRECT — Defer deep link handling
@State private var pendingDeepLink: URL?
@State private var isReady = false
var body: some View {
NavigationStack(path: $path) {
RootView()
.onAppear {
isReady = true
if let url = pendingDeepLink {
handleDeepLink(url)
pendingDeepLink = nil
}
}
}
.onOpenURL { url in
if isReady {
handleDeepLink(url)
} else {
pendingDeepLink = url // Queue for later
}
}
}// ❌ WRONG — Wrong order (child before parent)
// URL: myapp://category/desserts/recipe/apple-pie
func handleDeepLink(_ url: URL) {
path.append(recipe) // Recipe pushed first
path.append(category) // Category pushed second — WRONG ORDER
}
// User sees Category screen, not Recipe screen// ✅ CORRECT — Parent before child
func handleDeepLink(_ url: URL) {
path.removeLast(path.count) // Clear existing
// Build hierarchy: parent → child
path.append(category) // First: Category
path.append(recipe) // Second: Recipe (shows this screen)
}
// For complex paths, build array first
var newPath: [any Hashable] = []
// Parse URL segments...
newPath.append(category)
newPath.append(subcategory)
newPath.append(item)
// Then apply
path = NavigationPath(newPath)// ❌ WRONG — Single NavigationStack wrapping TabView
NavigationStack(path: $path) {
TabView {
Tab("Home") { HomeView() }
Tab("Settings") { SettingsView() }
}
}
// All tabs share same navigation — state mixed/lost
// ❌ WRONG — Same @State used across tabs
@State var path = NavigationPath() // Shared
TabView {
Tab("Home") {
NavigationStack(path: $path) { ... } // Uses shared path
}
Tab("Settings") {
NavigationStack(path: $path) { ... } // Same path!
}
}// ✅ CORRECT — Each tab has own NavigationStack
TabView {
Tab("Home", systemImage: "house") {
NavigationStack { // Own stack
HomeView()
.navigationDestination(for: HomeItem.self) { ... }
}
}
Tab("Settings", systemImage: "gear") {
NavigationStack { // Own stack
SettingsView()
.navigationDestination(for: SettingItem.self) { ... }
}
}
}
// For per-tab path tracking:
struct HomeTab: View {
@State private var path = NavigationPath() // Tab-specific
var body: some View {
NavigationStack(path: $path) {
HomeView()
}
}
}// ❌ WRONG — No persistence mechanism
@State private var path = NavigationPath()
// Path lost when app terminates// ✅ CORRECT — Use SceneStorage + Codable
struct ContentView: View {
@StateObject private var navModel = NavigationModel()
@SceneStorage("navigation") private var savedData: Data?
var body: some View {
NavigationStack(path: $navModel.path) {
RootView()
}
.task {
// Restore on appear
if let data = savedData {
navModel.restore(from: data)
}
// Save on changes
for await _ in navModel.objectWillChange.values {
savedData = navModel.encoded()
}
}
}
}
@MainActor
class NavigationModel: ObservableObject {
@Published var path = NavigationPath()
func encoded() -> Data? {
guard let codable = path.codable else { return nil }
return try? JSONEncoder().encode(codable)
}
func restore(from data: Data) {
guard let codable = try? JSONDecoder().decode(
NavigationPath.CodableRepresentation.self,
from: data
) else { return }
path = NavigationPath(codable)
}
}// Every type pushed on path needs a destination
// You push Recipe
path.append(recipe) // Recipe type
// But only registered Category
.navigationDestination(for: Category.self) { ... }
// No destination for Recipe!// Register ALL types you might push
NavigationStack(path: $path) {
RootView()
.navigationDestination(for: Category.self) { category in
CategoryView(category: category)
}
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
.navigationDestination(for: Chef.self) { chef in
ChefProfile(chef: chef)
}
}
// Or use enum route type for single registration
enum AppRoute: Hashable {
case category(Category)
case recipe(Recipe)
case chef(Chef)
}
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .category(let cat): CategoryView(category: cat)
case .recipe(let recipe): RecipeDetail(recipe: recipe)
case .chef(let chef): ChefProfile(chef: chef)
}
}// ❌ WRONG — Force unwrap decode
func restore(from data: Data) {
let codable = try! JSONDecoder().decode( // 💥 Crashes!
NavigationPath.CodableRepresentation.self,
from: data
)
path = NavigationPath(codable)
}
// Crash reasons:
// - Saved path contains type that no longer exists
// - Codable encoding changed between versions
// - Saved item was deleted// ✅ CORRECT — Graceful decode with fallback
func restore(from data: Data) {
do {
let codable = try JSONDecoder().decode(
NavigationPath.CodableRepresentation.self,
from: data
)
path = NavigationPath(codable)
} catch {
print("Navigation restore failed: \(error)")
path = NavigationPath() // Start fresh
// Optionally clear bad saved data
}
}
// ✅ BETTER — Store IDs, resolve to objects
class NavigationModel: ObservableObject, Codable {
var selectedIds: [String] = [] // Store IDs
func resolvedPath(dataModel: DataModel) -> NavigationPath {
var path = NavigationPath()
for id in selectedIds {
if let item = dataModel.item(withId: id) {
path.append(item)
}
// Missing items silently skipped
}
return path
}
}// Release build with diagnostic logging
#if DEBUG || DIAGNOSTIC
NavigationStack(path: $path) {
// ...
}
.onChange(of: path.count) { old, new in
Analytics.log("nav_path_change", ["old": old, "new": new])
}
#endif
// Check analytics for:
// - path.count going to 0 unexpectedly → Path recreation
// - path.count increasing but no push → Missing destination
// - No path changes at all → Link not firing// iOS 18 changes that affect navigation:
// 1. Stricter MainActor enforcement
// 2. Changes to view identity in TabView
// 3. New navigation lifecycle timing
// Most common iOS 18 issue:
// Code that worked by accident now fails
// Check: Any path modifications in async contexts without @MainActor?
Task {
let result = await fetch()
path.append(result) // ⚠️ iOS 18 stricter about this
}// Root cause found: NavigationPath modified from async context
// iOS 17 was lenient, iOS 18 enforces MainActor properly
// ❌ Old code (worked on iOS 17, breaks on iOS 18)
func loadAndNavigate() async {
let recipe = await fetchRecipe()
path.append(recipe) // Race condition
}
// ✅ Fix: Explicit MainActor isolation
@MainActor
func loadAndNavigate() async {
let recipe = await fetchRecipe()
path.append(recipe) // ✅ Safe
}
// OR: Annotate entire class
@Observable
@MainActor
class Router {
var path = NavigationPath()
func navigate(to value: any Hashable) {
path.append(value)
}
}// 1. Test on iOS 17 device — still works
// 2. Test on iOS 18 device — now works
// 3. Test all navigation paths
// 4. Submit expedited review
// Expedited review justification:
// "Critical bug fix for iOS 18 compatibility affecting 20% of users"Root cause identified: Navigation code wasn't properly isolated
to the main thread. iOS 18 enforces this more strictly than iOS 17.
Fix: Add @MainActor annotation to navigation code.
Already tested on iOS 17 (no regression) and iOS 18 (fixes issue).
Timeline:
- Fix ready: Now
- QA validation: 1 hour
- App Store submission: Today
- Available to users: 24-48 hours (expedited review)
Workaround for affected users: Force quit and relaunch app
often clears the issue temporarily.iOS 18 Navigation Fix
Root cause: NavigationPath modifications in async contexts
without @MainActor isolation. iOS 17 was permissive, iOS 18 enforces.
Fix applied:
- Added @MainActor to Router class
- Updated all path.append/removeLast calls to be MainActor-isolated
- Added Swift 6 concurrency checking to catch future issues
Files changed: Router.swift, ContentView.swift, DeepLinkHandler.swift
Testing needed:
- All navigation flows
- Deep links from cold start
- Tab switching with navigation state
- Background/foreground with navigation state| Symptom | Likely Cause | First Check | Pattern | Fix Time |
|---|---|---|---|---|
| Link tap does nothing | Link outside stack | View hierarchy | 1a | 5-10 min |
| Intermittent navigation failure | Destination in lazy container | Destination placement | 1b | 10-15 min |
| Works for some types, not others | Type mismatch | Print type(of:) | 1c | 10 min |
| Push then immediate pop | Path recreated | @State location | 2a | 15-20 min |
| Random unexpected pops | External path modification | Add logging | 2b | 15-20 min |
| Works on MainActor, fails in Task | Threading issue | Check @MainActor | 2d | 10-15 min |
| Deep link doesn't navigate | Not on MainActor | Thread check | 3a | 15-20 min |
| Deep link from cold start fails | Timing/lifecycle | Add pendingDeepLink | 3b | 15-20 min |
| Deep link shows wrong screen | Path order wrong | Print path contents | 3c | 10-15 min |
| State lost on tab switch | Shared NavigationStack | Check Tab structure | 4a | 15-20 min |
| State lost on background | No persistence | Add SceneStorage | 4b | 20-25 min |
| Crash on launch (decode) | Force unwrap decode | Error handling | 5c | 15-20 min |
| "No destination found" crash | Missing registration | List all types | 5b | 10-15 min |
// Move destination OUTSIDE lazy container
List {
ForEach(items) { item in
NavigationLink(item.name, value: item)
}
}
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item)
}NavigationViewNavigationStackNavigationSplitViewNavigationLink(title, value:)var body// Use @State, not computed
@State private var path = NavigationPath() // ✅ Persists
// NOT
var path: NavigationPath { NavigationPath() } // ❌ Reset every timetry?do/catchonOpenURLNavigationStackonAppearisReady