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
+2
View File
@@ -1,5 +1,7 @@
**July 2026** **July 2026**
Finishing an exercise from the watch no longer risks crashing the workout screen on the iPhone.
Deleting a workout no longer crashes the app. Deleting a workout no longer crashes the app.
While you rest between exercises, the workout screen now previews the next exercise's figure and name so you can see what's coming up. While you rest between exercises, the workout screen now previews the next exercise's figure and name so you can see what's coming up.
+7 -1
View File
@@ -62,8 +62,14 @@ extension WorkoutDocument {
/// map eagerly for *every* row in the parent list, including during the view-graph /// map eagerly for *every* row in the parent list, including during the view-graph
/// update that fires the instant a workout is deleted. Any map of an entity not /// update that fires the instant a workout is deleted. Any map of an entity not
/// already known to be live must go through this guard. /// already known to be live must go through this guard.
///
/// `isDeleted` alone is not enough: it is true only between `context.delete()` and
/// `context.save()`. Once the deletion is saved the model becomes *unregistered*
/// `isDeleted` reads false again, `modelContext` goes nil, and any persisted-property
/// read still traps. A `@Model` retained across time (not freshly fetched) can reach
/// this map in that state, so check both.
init?(fromLive workout: Workout) { init?(fromLive workout: Workout) {
guard !workout.isDeleted else { return nil } guard !workout.isDeleted, workout.modelContext != nil else { return nil }
self.init(from: workout) self.init(from: workout)
} }
+7 -3
View File
@@ -32,9 +32,13 @@ struct SplitDetailView: View {
// A closure-based `NavigationLink` builds this destination eagerly for every // A closure-based `NavigationLink` builds this destination eagerly for every
// row in the parent list, including during the update that fires when a split // row in the parent list, including during the update that fires when a split
// is deleted and reading any persisted property (even `id`) on a deleted // is deleted and reading any persisted property (even `id`) on a deleted
// `@Model` traps. An empty id maps to no live split, so `body` shows the // `@Model` traps. `isDeleted` alone misses a deletion that has already been
// "Split Unavailable" state and dismisses; the row is on its way out anyway. // saved (the model unregisters: `isDeleted` false again, `modelContext` nil,
_splitID = State(initialValue: split.isDeleted ? "" : split.id) // reads still trap), so check both. An empty id maps to no live split, so
// `body` shows the "Split Unavailable" state and dismisses; the row is on its
// way out anyway.
let live = !split.isDeleted && split.modelContext != nil
_splitID = State(initialValue: live ? split.id : "")
} }
private var split: Split? { private var split: Split? {
@@ -322,14 +322,31 @@ struct SplitPickerSheet: View {
/// fileobservercache loop round-trips, so we poll the cache for the entity and push it /// fileobservercache loop round-trips, so we poll the cache for the entity and push it
/// the moment it lands. /// the moment it lands.
private struct StartedWorkoutNavigator: ViewModifier { 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 @Environment(\.modelContext) private var modelContext
@Binding var pendingWorkoutID: String? @Binding var pendingWorkoutID: String?
@State private var resolvedWorkout: Workout? @State private var resolvedRun: RunRoute?
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.navigationDestination(item: $resolvedWorkout) { workout in .navigationDestination(item: $resolvedRun) { route in
WorkoutLogListView(workout: workout) // 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 .onChange(of: pendingWorkoutID) { _, id in
guard let id else { return } guard let id else { return }
@@ -344,8 +361,8 @@ private struct StartedWorkoutNavigator: ViewModifier {
Task { Task {
for _ in 0..<20 { for _ in 0..<20 {
try? await Task.sleep(for: .milliseconds(150)) try? await Task.sleep(for: .milliseconds(150))
if let workout = CacheMapper.fetchWorkout(id: id, in: modelContext) { if CacheMapper.fetchWorkout(id: id, in: modelContext) != nil {
resolvedWorkout = workout resolvedRun = RunRoute(id: id)
pendingWorkoutID = nil pendingWorkoutID = nil
return return
} }