From 85e15823a3abd535efb28f95fcfb848e5fdb0dc1 Mon Sep 17 00:00:00 2001 From: rzen Date: Fri, 10 Jul 2026 10:47:10 -0400 Subject: [PATCH] Guard NavigationLink destination inits against deleted @Models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A closure-based NavigationLink builds its destination view eagerly for every row on every parent-list body evaluation — including the view-graph update SwiftUI runs the instant SyncEngine.delete does context.delete + save. Mapping the row's just-deleted @Model to a document there reads a persisted property on a dead model, which traps in SwiftData (_InitialBackingData.getValue -> assertionFailure). This is the "Crashed when deleted a workout" TestFlight report on 2.3 (124). Add WorkoutDocument(fromLive:) (nil when isDeleted) + a .deletedPlaceholder and route every destination-init / updatedAt-absorb map through it: phone WorkoutLogListView + SplitDetailView (read split.id) and watch WorkoutLogListView. Fixes the whole crash class, not just the report. Claude-Session: https://claude.ai/code/session_01BQcEWmAPA78338QuEwRkAh --- CHANGELOG.md | 2 ++ Shared/Model/Mappers.swift | 24 +++++++++++++++++++ .../Views/WorkoutLogListView.swift | 12 +++++++--- Workouts/Views/Splits/SplitDetailView.swift | 7 +++++- .../WorkoutLogs/WorkoutLogListView.swift | 10 ++++++-- 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a1f4b3..c78e9f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ **July 2026** +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. Spoken cues can now announce the next exercise during a rest and count you into every set with "in 1, 2, 3, GO!". diff --git a/Shared/Model/Mappers.swift b/Shared/Model/Mappers.swift index a3e0d65..319ed44 100644 --- a/Shared/Model/Mappers.swift +++ b/Shared/Model/Mappers.swift @@ -54,6 +54,30 @@ extension WorkoutDocument { deletedLogIDs: workout.deletedLogIDs, restSeconds: workout.restSeconds, autoAdvance: workout.autoAdvance) } + + /// Maps a *live* cache entity to a document, or `nil` when that entity has already + /// been deleted from its SwiftData context. Reading a persisted property on a + /// deleted `@Model` traps (`_InitialBackingData.getValue` → `assertionFailure`), and + /// a closure-based `NavigationLink` builds its destination view — and so runs this + /// 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 + /// already known to be live must go through this guard. + init?(fromLive workout: Workout) { + guard !workout.isDeleted else { return nil } + self.init(from: workout) + } + + /// An inert stand-in for a destination whose backing entity is gone: the row is + /// being removed, so its detail screen is never actually shown. Reads nothing from + /// any entity, so it is safe to substitute for a deleted `@Model`. + static let deletedPlaceholder = WorkoutDocument( + schemaVersion: WorkoutDocument.currentSchemaVersion, + id: "", + start: Date(timeIntervalSinceReferenceDate: 0), + status: WorkoutStatus.notStarted.rawValue, + createdAt: Date(timeIntervalSinceReferenceDate: 0), + updatedAt: Date(timeIntervalSinceReferenceDate: 0), + logs: []) } // MARK: - Document → Cache (upsert) diff --git a/Workouts Watch App/Views/WorkoutLogListView.swift b/Workouts Watch App/Views/WorkoutLogListView.swift index eb66807..979a8b5 100644 --- a/Workouts Watch App/Views/WorkoutLogListView.swift +++ b/Workouts Watch App/Views/WorkoutLogListView.swift @@ -28,7 +28,11 @@ struct WorkoutLogListView: View { init(workout: Workout) { self.workout = workout - _doc = State(initialValue: WorkoutDocument(from: workout)) + // A closure-based `NavigationLink` builds this destination eagerly for every + // row in the parent list — including during the update that fires when a phone + // push removes a workout — so map only a live entity; a deleted one traps in + // SwiftData. The placeholder is never shown (the row is on its way out). + _doc = State(initialValue: WorkoutDocument(fromLive: workout) ?? .deletedPlaceholder) } /// The split this workout came from (read-only on the watch), used to offer @@ -109,8 +113,10 @@ struct WorkoutLogListView: View { // at. Clock skew across devices degrades this to a skipped absorb — // never to a clobber of local state. .onChange(of: workout.updatedAt) { _, incoming in - if incoming > doc.updatedAt { - doc = WorkoutDocument(from: workout) + // ...but never absorb from an entity a push has just deleted: mapping a + // deleted @Model traps. + if incoming > doc.updatedAt, let fresh = WorkoutDocument(fromLive: workout) { + doc = fresh } } .navigationDestination(item: $selectedLogID) { logID in diff --git a/Workouts/Views/Splits/SplitDetailView.swift b/Workouts/Views/Splits/SplitDetailView.swift index eb5e869..b3f5076 100644 --- a/Workouts/Views/Splits/SplitDetailView.swift +++ b/Workouts/Views/Splits/SplitDetailView.swift @@ -29,7 +29,12 @@ struct SplitDetailView: View { @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb init(split: Split) { - _splitID = State(initialValue: split.id) + // A closure-based `NavigationLink` builds this destination eagerly for every + // 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 + // `@Model` traps. 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. + _splitID = State(initialValue: split.isDeleted ? "" : split.id) } private var split: Split? { diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index afe68ee..cf45774 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -44,7 +44,11 @@ struct WorkoutLogListView: View { init(workout: Workout) { self.workout = workout - _doc = State(initialValue: WorkoutDocument(from: workout)) + // A closure-based `NavigationLink` builds this destination eagerly for every + // row in the parent list — including during the view-graph update that fires + // the instant a workout is deleted — so map only a live entity here; a deleted + // one traps in SwiftData. The placeholder is never shown (the row is gone). + _doc = State(initialValue: WorkoutDocument(fromLive: workout) ?? .deletedPlaceholder) } private var sortedLogs: [WorkoutLogDocument] { @@ -166,7 +170,9 @@ struct WorkoutLogListView: View { // Absorb edits made in pushed children (ExerciseView/Plan/Notes) once the // cache reflects them, so the list shows live status on return. .onChange(of: workout.updatedAt) { _, _ in - doc = WorkoutDocument(from: workout) + // Skip if the entity was deleted out from under us (e.g. a remote delete + // while this screen is open) — mapping a deleted @Model traps. + if let fresh = WorkoutDocument(fromLive: workout) { doc = fresh } } .toolbar { ToolbarItem(placement: .primaryAction) {