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
+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) {