Seemingly incorrect data when navigationDestination closure runs.

I have a very simple test app that shows a behavior I simply do not understand. I think this is a SwiftUI bug (filed as FB11518877), but I'm posting here in the hopes that maybe someone will see it and actually give me some kind of feedback/info (because I rarely get any via the Feedback app, ironically).

Here's the entire code:

struct ContentView: View {
    @State var data: [String] = []
    @State var navPath = NavigationPath()

    var body: some View {
        NavigationStack(path: $navPath) {
            Button("Hit It") {
                print("click")
                navPath.append(0)
            }
            .navigationDestination(for: Int.self) { index in
                let _ = print("index: \(index)  data: \(data.count)")
                if data.indices.contains(index) {
                    Text(data[index])
                } else {
                    Text("Not found.")
                }
            }
        }
        .task {
            data = ["Item 1", "Item 2", "Item 3"]
        }
    }
}

And here's a screenshot showing what happens when I click the button:

If you look down in the Console area, you can see that it prints out "click" and then the print statements tucked into the navigationDestination closure are also seen. For some reason there are 3 printouts (which seems odd, too), but the main problem seems to be that the 3rd printout indicates that the data array has suddenly become empty and so it returns the Text("Not found.") view instead of the one I expected.

Does anyone have any idea what's going on here?

This behavior is actually breaking our app when running on iOS 16.1 beta, but oddly enough, when I was trying to narrow it down and came up with this simple example, it turns out this also behaves this way on iOS 16. So something has changed in 16.1 to make this somehow worse in the case of our app, but it looks to me like this bug (I assume) has been around probably since WWDC.

Accepted Reply

We've heard through the grapevine from a SwiftUI engineer (oh how I wish we could hear back through the Feedback app so we didn't have to depend on prior social connections) that this was made worse by a known regression in iOS 16.1 beta which is expected to be fixed before 16.1 ships.

It was explained that there are actually two bugs being illustrated here in various ways and one has been around for the entire life of SwiftUI!

The longstanding SwiftUI bug is that under certain circumstances a ViewBuilder does not correctly create a dependency on @State data which means that when the data changes it might not realize it needs to update the view hierarchy again. This can cause captured ViewBuilder closures to operate on stale data. If I understand correctly, this primarily happens when a @State variable is being depended on in a closure and never as a bare parameter to a view. In this example, that applies to the data variable. We were told they really want to fix this bug, however I got the impression it's not going to be fixed soon - certainly not in the iOS 16.1 timeframe. If you hit this bug, it can be worked around by making sure your @State variable is used in the view hierarchy somewhere as a direct input to another view or view modifier somehow and not just inside the body of a ViewBuilder closure.

The second bug is that as of iOS 16.1 beta, the navigationDestination is apparently sometimes hanging on to a stale version of the closure. I believe that explains why in this example the final printout is "data: 0" instead of "data: 3". Our understanding is that this bug is fixed internally and it sounds like it's expected it will land before 16.1 actually ships.

In the meantime, I was able to workaround both of these bugs by using a clever bit of indirection. Moving the data array into an object was enough:

class WorkaroundDataHolder: ObservableObject {
    @Published var data: [String] = []
}

struct ContentView: View {
    @StateObject var workaround = WorkaroundDataHolder()
    @State var navPath = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $navPath) {
            Button("Hit It") {
                print("click")
                navPath.append(0)
            }
            .navigationDestination(for: Int.self) { index in
                let _ = print("index: \(index)  data: \(workaround.data.count)")
                if workaround.data.indices.contains(index) {
                    Text(workaround.data[index])
                } else {
                    Text("Not found.")
                }
            }
        }
        .task {
            workaround.data = ["Item 1", "Item 2", "Item 3"]
        }
    }
}

Replies

We've heard through the grapevine from a SwiftUI engineer (oh how I wish we could hear back through the Feedback app so we didn't have to depend on prior social connections) that this was made worse by a known regression in iOS 16.1 beta which is expected to be fixed before 16.1 ships.

It was explained that there are actually two bugs being illustrated here in various ways and one has been around for the entire life of SwiftUI!

The longstanding SwiftUI bug is that under certain circumstances a ViewBuilder does not correctly create a dependency on @State data which means that when the data changes it might not realize it needs to update the view hierarchy again. This can cause captured ViewBuilder closures to operate on stale data. If I understand correctly, this primarily happens when a @State variable is being depended on in a closure and never as a bare parameter to a view. In this example, that applies to the data variable. We were told they really want to fix this bug, however I got the impression it's not going to be fixed soon - certainly not in the iOS 16.1 timeframe. If you hit this bug, it can be worked around by making sure your @State variable is used in the view hierarchy somewhere as a direct input to another view or view modifier somehow and not just inside the body of a ViewBuilder closure.

The second bug is that as of iOS 16.1 beta, the navigationDestination is apparently sometimes hanging on to a stale version of the closure. I believe that explains why in this example the final printout is "data: 0" instead of "data: 3". Our understanding is that this bug is fixed internally and it sounds like it's expected it will land before 16.1 actually ships.

In the meantime, I was able to workaround both of these bugs by using a clever bit of indirection. Moving the data array into an object was enough:

class WorkaroundDataHolder: ObservableObject {
    @Published var data: [String] = []
}

struct ContentView: View {
    @StateObject var workaround = WorkaroundDataHolder()
    @State var navPath = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $navPath) {
            Button("Hit It") {
                print("click")
                navPath.append(0)
            }
            .navigationDestination(for: Int.self) { index in
                let _ = print("index: \(index)  data: \(workaround.data.count)")
                if workaround.data.indices.contains(index) {
                    Text(workaround.data[index])
                } else {
                    Text("Not found.")
                }
            }
        }
        .task {
            workaround.data = ["Item 1", "Item 2", "Item 3"]
        }
    }
}

This is a very common problem experienced by those new to Swift and haven't fully learned closures yet. Usually its experienced with sheet but its same thing with navigationDestination. If you want a closure to have the latest value you need to add it to the "capture list", e.g.

.navigationDestination(for: Int.self) { [data] index in

The explanation is when the closure is created it is using the old value, which is before it is actually used, so by adding it to the capture list you are stating you want the closure to be recreated when that value changes.

As for why the closure is called 3 times, it is annoying but if you take a look at how many times the ForEach closure is seemingly needlessly called that is even worse!

Post not yet marked as solved Up vote reply of malc Down vote reply of malc