Survive deleted-and-saved @Models on the started-run navigation path

TestFlight 2.3 (125) "crashed when watch ended an exercise": the
isDeleted guard from 85e1582 only covers the delete→save window. Once
the deletion is saved the model unregisters — isDeleted reads false
again, modelContext goes nil, and any persisted-property read still
traps (_InitialBackingData.getValue). StartedWorkoutNavigator retained
the run's @Model in @State for the whole workout, so an observer
remove/re-add churn (e.g. iCloud reachability flapping) invalidated it
underneath the pushed screen, and the watch's completion push triggered
the rebuild that read it.

Two layers: the fromLive/SplitDetailView guards now also require a
non-nil modelContext, and StartedWorkoutNavigator pushes a plain id
route, re-fetching the entity fresh on every destination build — a
re-imported run resolves to its live instance; a gone run shows a
placeholder instead of trapping.

Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
This commit is contained in:
2026-07-10 11:32:52 -04:00
parent 372b5dfb4d
commit 5c201289fb
4 changed files with 38 additions and 9 deletions
@@ -322,14 +322,31 @@ struct SplitPickerSheet: View {
/// fileobservercache loop round-trips, so we poll the cache for the entity and push it
/// the moment it lands.
private struct StartedWorkoutNavigator: ViewModifier {
/// The pushed state is a plain value wrapping the run's id never a retained
/// `@Model`. This screen stays pushed for the whole workout, and the run's entity
/// can be deleted and re-imported underneath it (observer remove/re-add churn, a
/// remote delete); a `@Model` held in `@State` would be *invalidated* by then
/// deleted-and-saved models read `isDeleted == false` yet trap on any property
/// access (the TestFlight 2.3 (125) "crashed when watch ended an exercise" report).
private struct RunRoute: Identifiable, Hashable { let id: String }
@Environment(\.modelContext) private var modelContext
@Binding var pendingWorkoutID: String?
@State private var resolvedWorkout: Workout?
@State private var resolvedRun: RunRoute?
func body(content: Content) -> some View {
content
.navigationDestination(item: $resolvedWorkout) { workout in
WorkoutLogListView(workout: workout)
.navigationDestination(item: $resolvedRun) { route in
// Re-fetch on every build so the destination always maps a live
// entity; a re-imported run resolves to its fresh instance.
if let workout = CacheMapper.fetchWorkout(id: route.id, in: modelContext) {
WorkoutLogListView(workout: workout)
} else {
ContentUnavailableView(
"Workout Unavailable",
systemImage: "xmark.circle",
description: Text("This workout is no longer on this device."))
}
}
.onChange(of: pendingWorkoutID) { _, id in
guard let id else { return }
@@ -344,8 +361,8 @@ private struct StartedWorkoutNavigator: ViewModifier {
Task {
for _ in 0..<20 {
try? await Task.sleep(for: .milliseconds(150))
if let workout = CacheMapper.fetchWorkout(id: id, in: modelContext) {
resolvedWorkout = workout
if CacheMapper.fetchWorkout(id: id, in: modelContext) != nil {
resolvedRun = RunRoute(id: id)
pendingWorkoutID = nil
return
}