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 // ingesting our latest edit) must not regress what the user is looking
// 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.isLive ? workout.updatedAt : nil) { _, incoming in
// ...but never absorb from an entity a push has just deleted: mapping a // ...and never absorb from an entity a push has just deleted: the observed
// deleted @Model traps. // expression above is evaluated on every body pass, so even it must be
if incoming > doc.updatedAt, let fresh = WorkoutDocument(fromLive: workout) { // liveness-guarded reading a persisted property on a dead @Model traps.
if let incoming, incoming > doc.updatedAt,
let fresh = WorkoutDocument(fromLive: workout) {
doc = fresh doc = fresh
} }
} }
+11 -5
View File
@@ -71,24 +71,30 @@ struct ExerciseView: View {
} }
.onAppear { .onAppear {
refreshDocIfNeeded() refreshDocIfNeeded()
// Take over this run: the watch parks and locks it while we're editing here. // 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) services.watchBridge.setEditingWorkout(workout.id)
} }
}
.onDisappear { .onDisappear {
services.watchBridge.setEditingWorkout(nil) services.watchBridge.setEditingWorkout(nil)
} }
// Reflect external changes (e.g. a set completed on the watch) live. Each edit // 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 // rewrites the whole workout file, so the cache always holds the latest pulling
// it in can't revert a newer local tap. // it in can't revert a newer local tap. The observed expression is evaluated on
.onChange(of: workout.updatedAt) { _, _ in // every body pass, so it must be liveness-guarded like any persisted read.
.onChange(of: workout.isLive ? workout.updatedAt : nil) { _, _ in
absorbExternalUpdate() absorbExternalUpdate()
} }
} }
/// Pull an externally-changed workout into the working copy so the plan and notes /// 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() { private func absorbExternalUpdate() {
doc = WorkoutDocument(from: workout) if let fresh = WorkoutDocument(fromLive: workout) { doc = fresh }
} }
@ViewBuilder @ViewBuilder
@@ -168,10 +168,10 @@ struct WorkoutLogListView: View {
} }
.navigationTitle(doc.routineName ?? Routine.unnamed) .navigationTitle(doc.routineName ?? Routine.unnamed)
// 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. The observed
.onChange(of: workout.updatedAt) { _, _ in // expression itself must be liveness-guarded too: it's evaluated on every
// Skip if the entity was deleted out from under us (e.g. a remote delete // body pass, and reading a persisted property on a dead @Model traps.
// while this screen is open) mapping a deleted @Model traps. .onChange(of: workout.isLive ? workout.updatedAt : nil) { _, _ in
if let fresh = WorkoutDocument(fromLive: workout) { doc = fresh } if let fresh = WorkoutDocument(fromLive: workout) { doc = fresh }
} }
.toolbar { .toolbar {
@@ -26,19 +26,22 @@ struct WorkoutSummaryView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Group { 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 { ScrollView {
VStack(spacing: 20) { VStack(spacing: 20) {
Image(systemName: "checkmark.seal.fill") Image(systemName: "checkmark.seal.fill")
.font(.system(size: 48)) .font(.system(size: 48))
.foregroundStyle(.green) .foregroundStyle(.green)
.padding(.top, 8) .padding(.top, 8)
Text(workout.routineName ?? Routine.unnamed) Text(doc.routineName ?? Routine.unnamed)
.font(.title2.bold()) .font(.title2.bold())
WorkoutMetricsView(workout: workout) WorkoutMetricsView(workout: workout)
if workout.metrics == nil { if doc.metrics == nil {
Label("Saving to Apple Health…", systemImage: "heart.text.square") Label("Saving to Apple Health…", systemImage: "heart.text.square")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -71,15 +74,21 @@ struct WorkoutMetricsView: View {
let workout: Workout let workout: Workout
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb @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? { private var durationSeconds: Int? {
guard let end = workout.end else { return nil } guard let doc, let end = doc.end else { return nil }
return max(0, Int(end.timeIntervalSince(workout.start))) return max(0, Int(end.timeIntervalSince(doc.start)))
} }
private var volume: Double { private var volume: Double {
metrics?.totalVolume ?? WorkoutVolume.total(WorkoutDocument(from: workout).logs) metrics?.totalVolume ?? WorkoutVolume.total(doc?.logs ?? [])
} }
var body: some View { var body: some View {