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:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
services.watchBridge.setEditingWorkout(workout.id)
|
// 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 {
|
.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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user