I'm seeing a memory leak in SwiftUI
when using NavigationStack
with .navigationTitle(...)
. Specifically, when a ViewModel
is stored in @State
inside a destination view, the view model's deinit
is never called after popping the view, unless I remove .navigationTitle
, .refreshable
, or both, or even ScrollView
.
Reproducible Example:
import SwiftUI
enum CoordinatorRoute: Hashable {
case detail
}
@Observable
class ViewModel {
init() {
print("🟢 ViewModel initialized")
}
func action() {
}
deinit {
print("❌ ViewModel deinitialized")
}
}
struct ContentView: View {
@State private var path: [CoordinatorRoute] = []
var body: some View {
NavigationStack(path: $path) {
VStack {
Button("Go to Detail") {
path.append(.detail)
}
}
.navigationTitle("Home") // Commenting this out prevents the leak
.navigationDestination(for: CoordinatorRoute.self) { route in
switch route {
case .detail:
DetailView()
}
}
}
}
}
struct DetailView: View {
@State var viewModel = ViewModel()
var body: some View {
ScrollView {
VStack {
Text("Detail View")
}
.navigationTitle("Detail") // Commenting this out prevents the leak
}
.refreshable {
viewModel.action() // Commenting this out prevents the leak
}
}
}
Tested using Xcode 16.2 and iOS 18.3 (on both simulator and physical device).
Just to clarify: is the only real solution here to manually detect and avoid combinations like .navigationTitle + .refreshable when using navigation?
Okay, the most refined solution so far is to do the following:
.refreshable { [weak viewModel] in
viewModel?.action()
}