Spurious View invalidation with NavigationStack and @Query with a predicate

I've run into a problem related to navigation links in child Views containing a SwiftData @Query and a predicate.

When tapping on a NavigationLinks, the containing View is invalidated pausing the UI. When tapping back, the View is invalidated a second time during which time the View ignores any new taps for navigation leading to a poor user experience.

A complete example:

import SwiftUI
import SwiftData

@Model
final class Item {
    var num: Int
    
    init(num: Int) {
        self.num = num
    }
}

@main
struct TestSwiftDataApp: App {
    var sharedModelContainer: ModelContainer = {
        let schema = Schema([Item.self])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)

        let container: ModelContainer
        do {
            container = try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
        
        // Add some sample data
        Task { @MainActor in
            for i in 0...1000 {
                container.mainContext.insert(Item(num: i))
            }
        }

        return container
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(sharedModelContainer)
    }
}

extension Color {
    static func random() -> Color {
        Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1))
    }
}

struct ContentView: View {
    var body: some View {
        NavigationStack {
            SubView()
                .navigationDestination(for: Item.self) { item in
                    Text("Item at \(item.num)")
                }
        }
    }
}

struct SubView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(filter: #Predicate<Item> { item in
        item.num < 20
    }, sort: \.num) private var items: [Item]
    
    var body: some View {
        let _ = Self._printChanges()
        List {
            ForEach(items) { item in
                NavigationLink(value: item) {
                    Text("Item \(item.num)")
                        
                }.background(Color.random())
            }
        }
    }
}

The background colors of cells will shift every invalidation. In addition there's some debugging in there to show what's happening. When running it, I get

SubView: @self, @identity, _modelContext, @128, @144 changed.
SubView: @self changed.
SubView: @dependencies changed.

Then I tap on an item and it invalidates:

SubView: @self changed.

Tapping back invalidates it again during which time the UI ignores new taps:

SubView: @self changed.

The odd thing is, this behavior doesn't happen if the NavigationStack is moved to the child View with the NavigationLinks like this:

struct ContentView2: View {
    var body: some View {
        SubView2()
    }
}

struct SubView2: View {
    @Environment(\.modelContext) private var modelContext
    @Query(filter: #Predicate<Item> { item in
        item.num < 20
    }, sort: \.num) private var items: [Item]
    
    var body: some View {
        let _ = Self._printChanges()
        NavigationStack {
            List {
                ForEach(items) { item in
                    NavigationLink(value: item) {
                        Text("Item \(item.num)")
                        
                    }.background(Color.random())
                }
            }
            .navigationDestination(for: Item.self) { item in
                Text("Item at \(item.num)")
            }
        }
    }
}

When running this, there's one less change as well and no invalidations on tap or back:

SubView: @self, @identity, _modelContext, @128, @144 changed.
SubView: @dependencies changed.

The problem also doesn't happen if the @Query does not have a filter #Predicate.

Unfortunately, the application in question has a deeper hierarchy where views with a @Query with a predicate can navigation to other views with a @Query and predicate, so neither solution seems ideal.

Is there some other way to stop the invalidations from happening?

Answered by Aloisius in 787837022

I seem to have come up with a workaround. Placing a View between the View containing the NavigationStack and the one containing the @Query with predicate filter appears to solve the problem. The view graph no longer gets invalidated when clicking to navigate away or back.

The resulting code looks like this:

struct ContentView: View {
    var body: some View {
        NavigationStack {
            let _ = Self._printChanges()
            MiddleView()
                .navigationDestination(for: Item.self) { item in
                    Text("Item at \(item.num)")
                }
        }
    }
}

struct MiddleView: View {
    var body: some View {
        let _ = Self._printChanges()
        SubView()
    }
}

struct SubView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(filter: #Predicate<Item> { item in
        item.num < 20
    }, sort: \.num) private var items: [Item]
    
    var body: some View {
        let _ = Self._printChanges()
        List {
            ForEach(items) { item in
                NavigationLink(value: item) {
                    Text("Item \(item.num)")
                }.background(Color.random())
            }
        }
    }
}

Not only that, but it appears that using NavigationLink(destination:, label: ) in the SubView seems to work now as well whereas before it would sometimes cause an infinite loop when navigating from a view with a Query predicate to another view with one.

Accepted Answer

I seem to have come up with a workaround. Placing a View between the View containing the NavigationStack and the one containing the @Query with predicate filter appears to solve the problem. The view graph no longer gets invalidated when clicking to navigate away or back.

The resulting code looks like this:

struct ContentView: View {
    var body: some View {
        NavigationStack {
            let _ = Self._printChanges()
            MiddleView()
                .navigationDestination(for: Item.self) { item in
                    Text("Item at \(item.num)")
                }
        }
    }
}

struct MiddleView: View {
    var body: some View {
        let _ = Self._printChanges()
        SubView()
    }
}

struct SubView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(filter: #Predicate<Item> { item in
        item.num < 20
    }, sort: \.num) private var items: [Item]
    
    var body: some View {
        let _ = Self._printChanges()
        List {
            ForEach(items) { item in
                NavigationLink(value: item) {
                    Text("Item \(item.num)")
                }.background(Color.random())
            }
        }
    }
}

Not only that, but it appears that using NavigationLink(destination:, label: ) in the SubView seems to work now as well whereas before it would sometimes cause an infinite loop when navigating from a view with a Query predicate to another view with one.

Spurious View invalidation with NavigationStack and @Query with a predicate
 
 
Q