Task, onAppear, onDisappear modifiers run twice

I've run into an issue with my app that I've been able to narrow down to a small reproducer.

Any time there is a task associated with the DetailView and you "pop to top", onAppear is called again and the task is re-run. Why is that? Is this a SwiftUI bug? It doesn't happen on iOS 17, only 18.

import SwiftUI
@Observable
class Store {
var shown: Bool = true
}
@main
struct MyApp: App {
@State private var store = Store()
var body: some Scene {
WindowGroup {
if store.shown {
ContentView()
} else {
EmptyView()
}
}
.environment(store)
}
}
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination: DetailView()) {
Text("Go to Detail View")
}
}
}
}
struct DetailView: View {
@Environment(Store.self) private var store
init() {
print("DetailView initialized")
}
var body: some View {
Button("Pop to top") {
store.shown = false
}
.task {
print("DetailView task executed")
}
.onAppear {
print("DetailView appeared")
}
.onDisappear {
print("DetailView disappeared")
}
}
}

I could not test on iOS 17 (my sample project crashes Xcode 15.3). Do you confirm it works there ?

I see the same on iOS 18:

DetailView initialized
DetailView appeared
DetailView task executed
-->> button tapped
DetailView disappeared
DetailView appeared
DetailView disappeared
DetailView task executed

This discussion may be interesting to read, even it did not let me understand what happens: https://fatbobman.com/en/posts/mastering_swiftui_task_modifier/

I was able to reproduce this on iOS 17 by animating the view transitions by changing the top level MyApp to:

@main
struct MyApp: App {
@State private var store = Store()
var body: some Scene {
WindowGroup {
if store.shown {
ContentView()
.transition(.move(edge: .bottom))
} else {
EmptyView()
.transition(.move(edge: .bottom))
}
}
.environment(store)
}
}

and the DetailView to:

struct DetailView: View {
@Environment(Store.self) private var store
var body: some View {
Button("Pop to top") {
withAnimation {
store.shown = false
}
}
.task {
print("DetailView task executed")
}
.onAppear {
print("DetailView appeared")
}
.onDisappear {
print("DetailView disappeared")
}
}
}

By the way, this only happens in this simple reproducer if the DetailView is shown from the ContentView within the NavigationView.

The difference between the output results in a completely different structural identity:

if store.shown {
ContentView()
} else {
EmptyView()
}

and

if store.shown {
ContentView()
}

The rendered results are the same, but the identity of the composed views differ. The former is a _ConditionalContent<TrueView,FalseView>, while the latter is Optional<View>.

Please submit a bug report regarding this issue using Feedback Assistant (https://feedbackassistant.apple.com) and post the Feedback ID Number here for the record.

Task, onAppear, onDisappear modifiers run twice
 
 
Q