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:
@@ -1,5 +1,7 @@
|
|||||||
**July 2026**
|
**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.
|
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!".
|
Spoken cues can now announce the next exercise during a rest and count you into every set with "in 1, 2, 3, GO!".
|
||||||
|
|||||||
@@ -54,6 +54,30 @@ extension WorkoutDocument {
|
|||||||
deletedLogIDs: workout.deletedLogIDs,
|
deletedLogIDs: workout.deletedLogIDs,
|
||||||
restSeconds: workout.restSeconds, autoAdvance: workout.autoAdvance)
|
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)
|
// MARK: - Document → Cache (upsert)
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ struct WorkoutLogListView: View {
|
|||||||
|
|
||||||
init(workout: Workout) {
|
init(workout: Workout) {
|
||||||
self.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
|
/// 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 —
|
// at. Clock skew across devices degrades this to a skipped absorb —
|
||||||
// never to a clobber of local state.
|
// never to a clobber of local state.
|
||||||
.onChange(of: workout.updatedAt) { _, incoming in
|
.onChange(of: workout.updatedAt) { _, incoming in
|
||||||
if incoming > doc.updatedAt {
|
// ...but never absorb from an entity a push has just deleted: mapping a
|
||||||
doc = WorkoutDocument(from: workout)
|
// deleted @Model traps.
|
||||||
|
if incoming > doc.updatedAt, let fresh = WorkoutDocument(fromLive: workout) {
|
||||||
|
doc = fresh
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(item: $selectedLogID) { logID in
|
.navigationDestination(item: $selectedLogID) { logID in
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ struct SplitDetailView: View {
|
|||||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||||
|
|
||||||
init(split: Split) {
|
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? {
|
private var split: Split? {
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ struct WorkoutLogListView: View {
|
|||||||
|
|
||||||
init(workout: Workout) {
|
init(workout: Workout) {
|
||||||
self.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] {
|
private var sortedLogs: [WorkoutLogDocument] {
|
||||||
@@ -166,7 +170,9 @@ struct WorkoutLogListView: View {
|
|||||||
// Absorb edits made in pushed children (ExerciseView/Plan/Notes) once the
|
// Absorb edits made in pushed children (ExerciseView/Plan/Notes) once the
|
||||||
// cache reflects them, so the list shows live status on return.
|
// cache reflects them, so the list shows live status on return.
|
||||||
.onChange(of: workout.updatedAt) { _, _ in
|
.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 {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
|||||||
Reference in New Issue
Block a user