Guard NavigationLink destination inits against deleted @Models

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
This commit is contained in:
2026-07-10 10:47:10 -04:00
parent 8b20d7ad08
commit 85e15823a3
5 changed files with 49 additions and 6 deletions
+2
View File
@@ -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!".
+24
View File
@@ -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)
@@ -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
+6 -1
View File
@@ -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? {
@@ -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) {