Guard every persisted read of retained workout models with isLive

A remotely deleted @Model traps on any persisted-property read, including
the observed expression of .onChange, which SwiftUI evaluates on every
body pass. Guard those expressions, the onAppear takeover read, and the
summary sheet's document mapping.

Claude-Session: https://claude.ai/code/session_01PKptrgbx74peTwHGRxBojv
This commit is contained in:
2026-07-12 00:37:05 -04:00
parent 8854ad59d5
commit 7b6c39d8f0
4 changed files with 38 additions and 21 deletions
@@ -112,10 +112,12 @@ struct WorkoutLogListView: View {
// ingesting our latest edit) must not regress what the user is looking
// at. Clock skew across devices degrades this to a skipped absorb
// never to a clobber of local state.
.onChange(of: workout.updatedAt) { _, incoming in
// ...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) {
.onChange(of: workout.isLive ? workout.updatedAt : nil) { _, incoming in
// ...and never absorb from an entity a push has just deleted: the observed
// expression above is evaluated on every body pass, so even it must be
// liveness-guarded reading a persisted property on a dead @Model traps.
if let incoming, incoming > doc.updatedAt,
let fresh = WorkoutDocument(fromLive: workout) {
doc = fresh
}
}
+12 -6
View File
@@ -71,24 +71,30 @@ struct ExerciseView: View {
}
.onAppear {
refreshDocIfNeeded()
// Take over this run: the watch parks and locks it while we're editing here.
services.watchBridge.setEditingWorkout(workout.id)
// Take over this run: the watch parks and locks it while we're editing
// here. Guarded even `id` is a persisted property and traps when read
// on an entity a remote delete has already reaped.
if workout.isLive {
services.watchBridge.setEditingWorkout(workout.id)
}
}
.onDisappear {
services.watchBridge.setEditingWorkout(nil)
}
// Reflect external changes (e.g. a set completed on the watch) live. Each edit
// rewrites the whole workout file, so the cache always holds the latest pulling
// it in can't revert a newer local tap.
.onChange(of: workout.updatedAt) { _, _ in
// it in can't revert a newer local tap. The observed expression is evaluated on
// every body pass, so it must be liveness-guarded like any persisted read.
.onChange(of: workout.isLive ? workout.updatedAt : nil) { _, _ in
absorbExternalUpdate()
}
}
/// Pull an externally-changed workout into the working copy so the plan and notes
/// update without leaving and re-entering the screen.
/// update without leaving and re-entering the screen. Skipped if the entity died
/// (remote delete) mapping a dead @Model traps.
private func absorbExternalUpdate() {
doc = WorkoutDocument(from: workout)
if let fresh = WorkoutDocument(fromLive: workout) { doc = fresh }
}
@ViewBuilder
@@ -168,10 +168,10 @@ struct WorkoutLogListView: View {
}
.navigationTitle(doc.routineName ?? Routine.unnamed)
// 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
// 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.
// cache reflects them, so the list shows live status on return. The observed
// expression itself must be liveness-guarded too: it's evaluated on every
// body pass, and reading a persisted property on a dead @Model traps.
.onChange(of: workout.isLive ? workout.updatedAt : nil) { _, _ in
if let fresh = WorkoutDocument(fromLive: workout) { doc = fresh }
}
.toolbar {
@@ -26,19 +26,22 @@ struct WorkoutSummaryView: View {
var body: some View {
NavigationStack {
Group {
if let workout {
// The fromLive guard also covers the query vending a workout whose
// cache row was deleted within the same view-graph update (remote
// delete / reconcile prune) reading a dead @Model traps.
if let workout, let doc = WorkoutDocument(fromLive: workout) {
ScrollView {
VStack(spacing: 20) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 48))
.foregroundStyle(.green)
.padding(.top, 8)
Text(workout.routineName ?? Routine.unnamed)
Text(doc.routineName ?? Routine.unnamed)
.font(.title2.bold())
WorkoutMetricsView(workout: workout)
if workout.metrics == nil {
if doc.metrics == nil {
Label("Saving to Apple Health…", systemImage: "heart.text.square")
.font(.footnote)
.foregroundStyle(.secondary)
@@ -71,15 +74,21 @@ struct WorkoutMetricsView: View {
let workout: Workout
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
private var metrics: WorkoutMetrics? { workout.metrics }
// Every read goes through this liveness-guarded snapshot: the cache row can be
// deleted out from under a retained `Workout` (remote delete, reconcile prune)
// while a parent re-evaluates mid-transition, and reading a persisted property
// on a dead @Model traps see `WorkoutDocument.init?(fromLive:)`.
private var doc: WorkoutDocument? { WorkoutDocument(fromLive: workout) }
private var metrics: WorkoutMetrics? { doc?.metrics }
private var durationSeconds: Int? {
guard let end = workout.end else { return nil }
return max(0, Int(end.timeIntervalSince(workout.start)))
guard let doc, let end = doc.end else { return nil }
return max(0, Int(end.timeIntervalSince(doc.start)))
}
private var volume: Double {
metrics?.totalVolume ?? WorkoutVolume.total(WorkoutDocument(from: workout).logs)
metrics?.totalVolume ?? WorkoutVolume.total(doc?.logs ?? [])
}
var body: some View {