NavigationLink and .navigationDestination Hang and Infinite Loop

I converted an existing app to use NavigationSplitView, NavigationStack, NavigationLink(value:), and .navigationDestination(for:). I found that this causes a hang with an infinite loop when I tap on many of the NavigationLink(value:). This happens in may different places in the app. Over the past few months I have tried to figure out what was happening without any luck. I ended up creating a simple test app called NavigationLinkIssue that demonstrates one of the places where I have the hang/infinite loop.

The loop can be seen by doing this:

  • Run
  • Tap on Push Second View
  • Tap on Push New/Different Instantiation of First View

The problem seem to be related to the report Binding. If I don’t make report a binding the hang/infinite loop goes away. If I change report to an ObservedObject the hang/infinite loop goes away.

I contacted Apple Developer Support, and had a few replies, but in the end they replied:

To clarify the issue you are experiencing is not a bug, it is expected. To elaborate, In Case1: This is expected because the ContentView creates an instance of FirstView with a Binding to the report object, which is then passed to SecondView. When the NavigationLink in SecondView is clicked, it navigates back to FirstView, which creates a new instance of FirstView with a new Binding to report. This causes the ContentView to update and create a new instance of SecondView, which then navigates back to FirstView, and so on, resulting in an infinite loop.

I have spent a lot of time looking at this and I still don’t understand why a Binding should cause the loop, especially if and ObservedObject does not. It still seems like a bug to me. Can anybody explain why this is causing a loop? The full code is below.

import SwiftUI
import OSLog

final class NavigationModel: ObservableObject {
	@Published var path = NavigationPath() {
		didSet {
			print("navigationModel.path.count: \(path.count)")
		}
	}
}

struct Report: Hashable {
	var title: String = "Report Title"
	var photo: UIImage? = UIImage(systemName: "z.circle")
}

private let logger = Logger()

struct ViewType: Identifiable, Hashable {
	let id: String
}

private var viewTypes = [
	ViewType(id: "First View"),
]

struct ContentView: View {
	@StateObject private var navigationModel = NavigationModel()
	@State var selection: Set<String> = [viewTypes[0].id]
	@State var report = Report(title: "ContentView Report")
	
	var body: some View {
		NavigationSplitView {
			List(viewTypes, selection: $selection) { viewType in
				Text("\(viewType.id)")
			}
		} detail: {
			if let viewTitle = selection.first {
				switch viewTitle {
				case "First View":
					FirstView(report: $report)
				default:
					Text("Invalid View")
				}
			}
		}
		.environmentObject(navigationModel)
	}
}

// FirstView

struct FirstView: View {
	@EnvironmentObject var navigationModel: NavigationModel
	@Binding var report: Report
	private enum NavigationLinkValueFirstView {
		case secondView
	}
	
	var body: some View {
		NavigationStack(path: $navigationModel.path) {
			NavigationLink(value: NavigationLinkValueFirstView.secondView) {
				let _ = logger.debug("FirstView NavigationLink")
				Text("Push Second View")
			}
			.navigationDestination(for: NavigationLinkValueFirstView.self) { value in
				let _ = logger.debug("FirstView navigationDestination")
				SecondView(report: $report)
			}
		}
		.navigationTitle("First View")
	}
}

// SecondView

struct SecondView: View {
	@State var showTheView = false
	@Binding var report: Report
	private enum NavigationLinkValueSecondView {
		case firstView
	}
	
	var body: some View {
		NavigationLink(value: NavigationLinkValueSecondView.firstView) {
			let _ = logger.debug("SecondView NavigationLink")
			Text("Push New/Different Instantiation of First View")
		}
		.navigationDestination(for: NavigationLinkValueSecondView.self) { value in
			FirstView(report: $report)
			let _ = logger.debug("SecondView navigationDestination")
		}
		.navigationTitle("Second View")
	}
}
Answered by Frameworks Engineer in 749141022

You're pushing multiple stacks that share the same path. So one stack is trying to push another stack which is trying to push the other stack....

It's a happy coincidence that ObservableObject doesn't infinite loop, I'm surprised it does not.

2 general rules will help when using the navigation system:

  1. move navigationDestination modifiers as high up in the view hierarchy. This is more efficient for the Navigation system to read up front than with potentially every view update.
  2. Don't push stacks onto stacks, and try to avoid entire NavigationStacks coming and going from the columns of a NavigationSplitView. NSV will "adopt" the stack and integrate its state with the state of the whole split view.

I ran this example on macOS and it looked as expected

import SwiftUI
import OSLog

final class NavigationModel: ObservableObject {
    @Published var path = NavigationPath() {
        didSet {
            print("navigationModel.path.count: \(path.count)")
        }
    }
}

struct Report: Hashable {
    var title: String = "Report Title"
}

private let logger = Logger()

struct ViewType: Identifiable, Hashable {
    let id: String
}

private var viewTypes = [
    ViewType(id: "First View"),
]

struct MyContentView: View {
    @StateObject private var navigationModel = NavigationModel()
    @State var selection: Set<String> = [viewTypes[0].id]
    @State var report = Report(title: "ContentView Report")

    var body: some View {
        NavigationSplitView {
            List(viewTypes, selection: $selection) { viewType in
                Text("\(viewType.id)")
            }
        } detail: {
            NavigationStack(path: $navigationModel.path) {
                Group {
                    if let viewTitle = selection.first {
                        switch viewTitle {
                        case "First View":
                            FirstView(report: $report)
                        default:
                            Text("Invalid View")
                        }
                    }
                }
                .navigationDestination(for: NavigationLinkValueFirstView.self) { value in
                    let _ = logger.debug("FirstView navigationDestination")
                    SecondView(report: $report)
                }
                .navigationDestination(for: NavigationLinkValueSecondView.self) { value in
                    FirstView(report: $report)
                    let _ = logger.debug("SecondView navigationDestination")
                }
            }
        }
        .environmentObject(navigationModel)
    }
}

// FirstView
private enum NavigationLinkValueFirstView {
    case secondView
}

struct FirstView: View {
    @EnvironmentObject var navigationModel: NavigationModel
    @Binding var report: Report

    var body: some View {
        NavigationLink(value: NavigationLinkValueFirstView.secondView) {
            let _ = logger.debug("FirstView NavigationLink")
            Text("Push Second View")
        }
        .navigationTitle("First View")
    }
}

// SecondView
private enum NavigationLinkValueSecondView {
    case firstView
}

struct SecondView: View {
    @State var showTheView = false
    @Binding var report: Report

    var body: some View {
        NavigationLink(value: NavigationLinkValueSecondView.firstView) {
            let _ = logger.debug("SecondView NavigationLink")
            Text("Push New/Different Instantiation of First View")
        }
        .navigationTitle("Second View")
    }
}

When I test, I get the log error:

A navigationDestination for “app.FirstView.(unknown context at $102e7ba28).NavigationLinkValueFirstView” was declared earlier on the stack. Only the destination declared closest to the root view of the stack will be used.

Do you get it when using ObservedObject ? Could you show the code with ObservedObject instead of Binding ?

Here is the code using an ObservedObject.

import SwiftUI
import OSLog

final class NavigationModel: ObservableObject {
	@Published var path = NavigationPath() {
		didSet {
			print("navigationModel.path.count: \(path.count)")
		}
	}
}

class Report: ObservableObject {
	@Published var title: String
	@Published var photo: UIImage?
	
	init(title: String, photo: UIImage? = UIImage(systemName: "z.circle")) {
		self.title = title
		self.photo = photo
	}
}

private let logger = Logger()

struct ViewType: Identifiable, Hashable {
	let id: String
}

private var viewTypes = [
	ViewType(id: "First View"),
]

struct ContentView: View {
	@StateObject private var navigationModel = NavigationModel()
	@State var selection: Set<String> = [viewTypes[0].id]
	@StateObject var report = Report(title: "ContentView Report")
	
	var body: some View {
		NavigationSplitView {
			List(viewTypes, selection: $selection) { viewType in
				Text("\(viewType.id)")
			}
		} detail: {
			if let viewTitle = selection.first {
				switch viewTitle {
				case "First View":
					FirstView(report: report)
				default:
					Text("Invalid View")
				}
			}
		}
		.environmentObject(navigationModel)
	}
}

// FirstView

struct FirstView: View {
	@EnvironmentObject var navigationModel: NavigationModel
	@ObservedObject var report: Report
	private enum NavigationLinkValueFirstView {
		case secondView
	}
	
	var body: some View {
		NavigationStack(path: $navigationModel.path) {
			NavigationLink(value: NavigationLinkValueFirstView.secondView) {
				let _ = logger.debug("FirstView NavigationLink")
				Text("Push Second View")
			}
			.navigationDestination(for: NavigationLinkValueFirstView.self) { value in
			let _ = logger.debug("FirstView navigationDestination")
			SecondView(report: report)
			}
		}
		.navigationTitle("First View")
	}
}

// SecondView

struct SecondView: View {
	@State var showTheView = false
	@ObservedObject var report: Report
	private enum NavigationLinkValueSecondView {
		case firstView
	}
	
	var body: some View {
		NavigationLink(value: NavigationLinkValueSecondView.firstView) {
			let _ = logger.debug("SecondView NavigationLink")
			Text("Push New/Different Instantiation of First View")
		}
		.navigationDestination(for: NavigationLinkValueSecondView.self) { value in
			FirstView(report: report)
			let _ = logger.debug("SecondView navigationDestination")
		}
		.navigationTitle("Second View")
	}
}

struct ContentView_Previews: PreviewProvider {
	static var previews: some View {
		ContentView()
	}
}
Accepted Answer

You're pushing multiple stacks that share the same path. So one stack is trying to push another stack which is trying to push the other stack....

It's a happy coincidence that ObservableObject doesn't infinite loop, I'm surprised it does not.

2 general rules will help when using the navigation system:

  1. move navigationDestination modifiers as high up in the view hierarchy. This is more efficient for the Navigation system to read up front than with potentially every view update.
  2. Don't push stacks onto stacks, and try to avoid entire NavigationStacks coming and going from the columns of a NavigationSplitView. NSV will "adopt" the stack and integrate its state with the state of the whole split view.

I ran this example on macOS and it looked as expected

import SwiftUI
import OSLog

final class NavigationModel: ObservableObject {
    @Published var path = NavigationPath() {
        didSet {
            print("navigationModel.path.count: \(path.count)")
        }
    }
}

struct Report: Hashable {
    var title: String = "Report Title"
}

private let logger = Logger()

struct ViewType: Identifiable, Hashable {
    let id: String
}

private var viewTypes = [
    ViewType(id: "First View"),
]

struct MyContentView: View {
    @StateObject private var navigationModel = NavigationModel()
    @State var selection: Set<String> = [viewTypes[0].id]
    @State var report = Report(title: "ContentView Report")

    var body: some View {
        NavigationSplitView {
            List(viewTypes, selection: $selection) { viewType in
                Text("\(viewType.id)")
            }
        } detail: {
            NavigationStack(path: $navigationModel.path) {
                Group {
                    if let viewTitle = selection.first {
                        switch viewTitle {
                        case "First View":
                            FirstView(report: $report)
                        default:
                            Text("Invalid View")
                        }
                    }
                }
                .navigationDestination(for: NavigationLinkValueFirstView.self) { value in
                    let _ = logger.debug("FirstView navigationDestination")
                    SecondView(report: $report)
                }
                .navigationDestination(for: NavigationLinkValueSecondView.self) { value in
                    FirstView(report: $report)
                    let _ = logger.debug("SecondView navigationDestination")
                }
            }
        }
        .environmentObject(navigationModel)
    }
}

// FirstView
private enum NavigationLinkValueFirstView {
    case secondView
}

struct FirstView: View {
    @EnvironmentObject var navigationModel: NavigationModel
    @Binding var report: Report

    var body: some View {
        NavigationLink(value: NavigationLinkValueFirstView.secondView) {
            let _ = logger.debug("FirstView NavigationLink")
            Text("Push Second View")
        }
        .navigationTitle("First View")
    }
}

// SecondView
private enum NavigationLinkValueSecondView {
    case firstView
}

struct SecondView: View {
    @State var showTheView = false
    @Binding var report: Report

    var body: some View {
        NavigationLink(value: NavigationLinkValueSecondView.firstView) {
            let _ = logger.debug("SecondView NavigationLink")
            Text("Push New/Different Instantiation of First View")
        }
        .navigationTitle("Second View")
    }
}

I also just ran the ObservableObject code and it actually logs about cycle detection. SwiftUI has some internal cycle detection that will catch these loop conditions and log when it does. The app can be considered in an unknown state if the cycle detection stops and update to prevent an infinite loop.

NavigationLink and .navigationDestination Hang and Infinite Loop
 
 
Q