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.

We hit what looks like the same lifecycle double-execution discussed here, investigated it with a single-variable-per-run bisect, and filed two Feedbacks with minimal repro projects:

  • FB24753146 — NavigationSplitView (compact/iPhone): wrapping the detail column's root in a NavigationStack causes the pushed detail to be detached and re-inserted mid-push. Its .task is cancelled; with .tag sidebar rows it re-attaches and the task runs twice, but with NavigationLink(value:) rows it never re-attaches — the view stays visible with a dead task (permanent spinner). Deleting the NavigationStack wrapper makes the identical structure clean across launch, repeated pushes, and sidebar refreshes. A bare .navigationDestination on the detail root reproduces it too.
  • FB24753158 — NavigationSplitView (regular/iPad): changing the sidebar selection while the detail's stack has pushed content always detaches the incoming detail root once (onDisappear while visible → .task cancelled → re-run). The pop-to-root and root-swap are processed in separate beats; clearing a bound path synchronously in the selection setter does not prevent it.

Reproduced on iOS 26 (latest, hardware) and the iOS 27.0 simulator. Repro project (both cases, with the one-line toggle that flips clean/broken): github.com/…/navigationsplitview-detail-detach

Notably this happens with stable identity — one model init, @State preserved throughout — so it isn't the _ConditionalContent identity issue alone; the trigger is specifically the NavigationStack (or a navigationDestination) attached to the split view's detail column root.

Demo repo can be found at https://github.com/zeyrie/navigationsplitview-detail-detach.git

Task, onAppear, onDisappear modifiers run twice
 
 
Q