NavigationStack memory leak?

I am running into an example where if you have a View that has a NavigationStack in it, and it appears and disappears based on a conditional, it doesn't seem to get fully torn down?

This appears to matter most when it is also initializing a ViewModel, because it will reinit without deiniting.

So for example

struct ContentView: View {
    @State var showNavStack:Bool = false
    var body: some View {
        Button("ShowHide") {
            showNavStack.toggle()
        }.padding(20)
        if showNavStack {
            SimpleRootView()
        }
    }
}

With the NavigationStack view

import SwiftUI

class DoNothingVM:ObservableObject {
    deinit {
        print("ViewModel DEINIT")
    }

    init() {
        print("ViewModel INIT")
    }
}

struct SimpleRootView: View {
    @StateObject var viewModel = DoNothingVM()
    @State var path = NavigationPath()

    let int = Int.random(in: 0...100)

    var body: some View {
        NavigationStack(path: $path) {
            VStack {
                Text("Hello \(int)")
                Button("Go Forward") {
                    path.append(Int.random(in: 0...100))
                }
            }.navigationDestination(for: Int.self) { int in
                DetailIntView(int: int, path: $path)
            }.navigationTitle("\(int)")
        }
    }
}

struct DetailIntView:View {
    let int:Int
    @Binding var path:NavigationPath

    var body: some View {
        VStack {
            Text("Hello \(int)")
            Button("Go Forward") {
                path.append(Int.random(in: 0...100))
            }
        }.navigationTitle("\(int)")
    }
}

Will lead to multiple calls to a DoNothing init every time the showNavStack is toggled to true, but no deinit when toggled to false. Multiples remain in memory.

"So don't do that" is fine for me in my case, but I'm wondering if this is expected behavior? Is it simply that SwiftUI only expects One and Only One NavigationStack per app and that it will be the only thing in charge? I haven't tested it, but what does that mean for coming back out of the background? This seems like a bug, but it might also be my misunderstanding.

Project where I'm messing around with this here: https://github.com/carlynorama/NavigationExplorer

Post not yet marked as solved Up vote post of carlynorama Down vote post of carlynorama
1.6k views
  • Feedback: FB11643551

  • This stack overflow question seems to show similar behavior when the flow control is a switch statement not an if statement. It's been added to the sample app.

    https://stackoverflow.com/questions/73941284/why-are-objects-still-in-memory-after-emptying-navigationstack-path/73954020
Add a Comment

Replies

As a confirmation test I embed the NavigationStack in a SplitView and verified the viewModels do deint when the view is removed from the screen (forcibly with a .id() call), so memory clean up happens correctly within the Navigation ecosystem.

struct SplitViewLanding: View {
    var options = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"]

    //NOTE: the selection binding MUST be an optional. 
    @State var selection:String?

    var body: some View {
        NavigationSplitView {
            //This button removes the view from the screen but the DEINIT is not called until a new view is selected to replace it. A hint to how the memory leak is happening when the NavigationStack is not managed by the Navigation framework. 
            Button("Nil Selection") { selection = nil }
            List(options, id:\.self, selection: $selection) { o in
                Text("\(o)")
            }
        } detail: {
            if let selection {
                //inits and deinits viewModel
                RootForDetailView(for: selection).id(selection)
                //inits once and then updates vm with the current selection IF AND ONLY IF you have code to do so "onAppear"
                //RootForDetailView(for: selection)
            }

        }
    }
}
class DetailDoNothingVM:ObservableObject {
    @Published var optionSet:String

    deinit {
        print("DetailDoNothingVM DEINIT")
    }

    init() {
        print("DetailDoNothingVM INIT")
        self.optionSet = "default"
    }

}

struct RootForDetailView: View {
    @StateObject var viewModel = DetailDoNothingVM()

    let optionSet:String

    init(for optionSet:String) {
        self.optionSet = optionSet
    }

    @State var path = NavigationPath()

    let int = Int.random(in: 0...100)

    var body: some View {
        NavigationStack(path: $path) {
            VStack {
                Text("Hello \(int)")
                Button("Go Forward") {
                    path.append(Int.random(in: 0...100))
                }
            }.navigationDestination(for: Int.self) { int in
                DetailOptionIntView(int: int, path: $path).environmentObject(viewModel)
            }.navigationTitle("\(int)")
        }.onAppear() {
             //Necessary to update model if you don't mark the call to this view with a .id because the init will only be called once
            viewModel.optionSet = optionSet
        }
    }
}

struct DetailOptionIntView:View {
    let int:Int
    @Binding var path:NavigationPath
    @EnvironmentObject var viewModel:DetailDoNothingVM

    var body: some View {
        VStack {
            Text("Hello \(int)")
            Button("Go Forward") {
                path.append(Int.random(in: 0...100))
            }
        }.navigationTitle("\(viewModel.optionSet):\(int)")
    }
}
  • "verified the viewModels do deint when the view is removed from the screen" should actually read verified the "viewModels do deint when the next view is added" as they do not immediately deint upon removal (see comment over Button("Nil Selection") { selection = nil } in SplitViewLanding)

Add a Comment

Still experiencing this in iOS17. Only happens when using NavigationStack with path.