A model’s == isn’t just a comparison anymore. Under Observation, it’s a gate. It decides whether anyone hears about a write at all.
I found that out in Workout Wanderer, my HealthKit and MapKit app. It maps your workout routes on iPhone and iPad, paints them as heat maps (heart rate, pace, elevation, cadence, power, and a few more), and lets you compare workouts side by side. Workouts load in two phases. First comes the basic list. Then background enrichment fills in each workout’s route, city name, heart-rate summary, elevation stats, and the detailed series the heat maps draw from.
Three commits landed on September 27, and all three trace back to one struct. The first fixed an == that was too loose, so observers never heard about enrichment. The other two fixed observers that were too broad, so two map cameras reacted to every enrichment write. The bugs point in opposite directions, but they’re the same bug. And I only found the second one because I fixed the first.
Dirty Window — An id-only == on a Two-Phase Model
Here’s WorkoutData before the fix, trimmed:
struct WorkoutData: Identifiable, Equatable {
let id: UUID
let workout: HKWorkout
var cityName: String?
var routeCoordinates: [CLLocationCoordinate2D] = []
var heartRateData: (low: Double, avg: Double, high: Double)?
var routeLocations: [WorkoutLocation] = []
var detailedHeartRateData: WorkoutHeartRateData?
var detailedPaceData: WorkoutPaceData?
// ...detailedElevationData, detailedCadenceData, detailedRunningPowerData,
// detailedVerticalOscillationData, detailedGAPData, detailedPowerEffortData
var cachedElevationStatistics: ElevationStatistics?
static func == (lhs: WorkoutData, rhs: WorkoutData) -> Bool {
lhs.id == rhs.id
}
}
Hashable lives in an extension and hashes the same single field:
extension WorkoutData: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
An id-only == is easy to write and easy to defend. Two values with the same id are the same workout, so they’re equal. For a model that never changes after it’s created, that’s close enough. WorkoutData changes after it’s created. That’s the whole point of phase two. A workout with no route and a workout with a full route, a city, and eight heat-map series all compared equal as long as they shared a UUID.
The commit message says what that cost: “SwiftUI observers of workoutsData (and of an enriched selectedWorkout) never saw routes, city names or heat-map data arrive.”
That “never” deserves a footnote, and the footnote is the most interesting part of the whole fix.
The House Jack Built — What @Observable Generates for a Setter
I went and read what the @Observable macro actually generates in the Swift 6.4 toolchain. For a stored property like selectedWorkout, the setter looks like this:
set {
guard shouldNotifyObservers(_selectedWorkout, newValue) else {
_selectedWorkout = newValue
return
}
withMutation(keyPath: \.selectedWorkout) {
_selectedWorkout = newValue
}
}
shouldNotifyObservers is overloaded. For T: Equatable it returns lhs != rhs. For T: AnyObject it returns lhs !== rhs. For T: Equatable & AnyObject it goes back to lhs != rhs. The unconstrained version returns true.
WorkoutData? is Equatable. So is [WorkoutData], and an array’s == asks each element’s ==. Assign a value that your == calls equal, and Observation takes the early exit. It stores the new value and notifies nobody.
Read that twice, because it’s not the bug you’d guess. The data isn’t lost. It’s sitting in the backing storage. Any view that re-renders later for some unrelated reason reads it and draws it. That’s the kind of bug that looks intermittent, and it’s completely deterministic.
The footnote gets one level deeper. The macro also generates a _modify accessor, and that’s what in-place mutation goes through: workoutsData[index] = merged. _modify calls the registrar’s willSet and didSet unconditionally. No equality check. Whole-value assignment goes through set and hits the guard: workoutsData = mergingLoadedEnrichment(into: fetchedWorkouts), uiModel.selectedWorkout = finalWorkout.
So some enrichment writes notified and others didn’t, and the difference came down to the shape of the line that did the writing. Two lines that mean the same thing to a reader meant different things to Observation.
onChange(of:) was blind for a simpler reason. It compares old and new with == too. Any .onChange(of: uiModel.selectedWorkout) in the app was, in effect, .onChange(of: uiModel.selectedWorkout?.id) already. Nobody wrote it that way. Nobody had to.
Purify — A Hand-Written == That’s Sufficient, Not Exhaustive
The textbook fix is to delete the hand-written == and let the compiler synthesize one. That wasn’t available here. CLLocationCoordinate2D isn’t Equatable, and a tuple can’t conform to Equatable, so an optional tuple has no == to synthesize with. Even if synthesis worked, it would compare two route arrays element by element on every diff, and workout routes are long.
So the new == is still hand-written. It’s just honest now:
// Route arrays compare by count only: this runs on every SwiftUI onChange diff of workoutsData.
static func == (lhs: WorkoutData, rhs: WorkoutData) -> Bool {
lhs.id == rhs.id &&
lhs.routeLocations.count == rhs.routeLocations.count &&
lhs.routeCoordinates.count == rhs.routeCoordinates.count &&
lhs.cityName == rhs.cityName &&
sameHeartRateSummary(lhs.heartRateData, rhs.heartRateData) &&
lhs.cachedElevationStatistics == rhs.cachedElevationStatistics &&
lhs.detailedHeartRateData == rhs.detailedHeartRateData &&
lhs.detailedPaceData == rhs.detailedPaceData &&
lhs.detailedElevationData == rhs.detailedElevationData &&
lhs.detailedCadenceData == rhs.detailedCadenceData &&
lhs.detailedRunningPowerData == rhs.detailedRunningPowerData &&
lhs.detailedVerticalOscillationData == rhs.detailedVerticalOscillationData &&
lhs.detailedGAPData == rhs.detailedGAPData &&
lhs.detailedPowerEffortData == rhs.detailedPowerEffortData
}
private static func sameHeartRateSummary(
_ lhs: (low: Double, avg: Double, high: Double)?,
_ rhs: (low: Double, avg: Double, high: Double)?
) -> Bool {
switch (lhs, rhs) {
case (nil, nil): true
case (let lhs?, let rhs?): lhs == rhs
default: false
}
}
The goal isn’t an exhaustive ==. It’s a sufficient one. == has to change whenever anything an observer cares about changes. It doesn’t have to prove two values are identical down to the last coordinate.
Route counts work as a change signal because of how routes arrive in this app: whole. A workout has zero points, and then it has all of them. The trade-off is real, though. Count-only is deliberate (the comment says so), and it has a blind spot: if a route were ever replaced by a different one with the same number of points, this == wouldn’t see it. That’s fine for how the app loads data today. If that ever changes, this is the first place I’d look.
The heat-map series cost almost nothing here, because each detailed* type already had its own cheap ==. The heart-rate summary needs the little helper because the stdlib compares tuples of Double just fine, but optional tuples get no == at all.
The Test That Guarded the Bug
A hand-written == over thirteen enrichment fields has an obvious failure mode: someone adds one more and forgets it. So the new test runs once per enrichment field:
@Test("Each enrichment field on its own makes same-id copies unequal", arguments: EnrichedField.allCases)
func equalitySeesEachEnrichmentField(field: EnrichedField) {
let basic = WorkoutFixtures.makeWorkoutData()
#expect(basic != field.applied(to: basic))
}
EnrichedField is a CaseIterable enum with one case per enrichment field. applied(to:) returns a copy with only that field set. Add a field to the model and a case to the enum, and the test tells you whether == noticed.
My favorite diff in the commit is smaller. An existing test had been asserting the bug as a feature:
// Should still be equal (equality based on ID only)
#expect(workoutData1 == workoutData2)
It now reads:
// Loaded enrichment makes same-id copies distinguishable
#expect(workoutData1 != workoutData2)
The suite wasn’t failing to catch the bug. It was defending it. A green test only tells you the code does what the test says. It doesn’t tell you the test is saying the right thing.
Confusion — Identity and Equality Answer Different Questions
Making == honest broke every caller that had been using it to ask a different question.
An id-only == quietly carries a second contract. Callers read a == b as “is this the same workout?” and for a long time the answer was right. Once == started meaning “does this look the same?”, every one of those callers changed behavior. The commit lists them.
The iPhone and iPad selection observers start a load when the selection changes. With a strict ==, every enrichment write to the selected copy would look like a new selection, so they’d re-trigger loadWorkoutData, and on iPad they’d also un-minimize the detail panel. The iPhone version shows the pattern:
// before
.onChange(of: uiModel.selectedWorkout) { _, newWorkout in
handleWorkoutSelectionChange(newWorkout)
}
// after
.onChange(of: uiModel.selectedWorkout?.id) { _, _ in
handleWorkoutSelectionChange(uiModel.selectedWorkout)
}
The trigger is the id. The payload is read fresh from the model. Those are two separate decisions, and the old code had fused them.
The iPad map-centering observer and WorkoutUIModel’s camera didSet should recenter only when the selection actually changes:
var selectedWorkout: WorkoutData? {
didSet {
if selectedWorkout?.id != oldValue?.id { // was: selectedWorkout != oldValue
updateCameraForWorkoutSelection()
}
}
}
The subtle one was comparison. ComparisonState keeps a Set<WorkoutData>. Hashing is still id-only while == is stricter. That pairing is still legal, because values that compare equal share an id and therefore a hash. But contains finds the right bucket and then asks ==, so it now needs the same value, not the same workout. A copy of a selected workout that picked up its city name afterward wouldn’t register as selected, and couldn’t be deselected. So membership asks about identity explicitly:
func isSelected(_ workout: WorkoutData) -> Bool {
selectedWorkouts.contains { $0.id == workout.id }
}
private func deselect(_ workout: WorkoutData) {
guard let selected = selectedWorkouts.first(where: { $0.id == workout.id }) else { return }
selectedWorkouts.remove(selected)
}
deselect removes the element that’s actually stored, found by id, not the argument it was handed. The test for it is named exactly for the case: “A city-enriched copy of a selected workout is recognised as selected and toggles it off.” It selects a workout, builds the same workout with cityName = "Portland", checks that isSelected sees it, and toggles it off.
Identifiable and id answer “is this the same thing?” == answers “is this still the same?” When one type answers both with the same function, you can’t fix one answer without breaking every caller that wanted the other. When you make == honest, go find everyone who was asking about identity and have them ask .id directly.
Shadows Follow — Triggers That Fire on Every Enrichment Write
With enrichment visible, two cameras started moving too often. That’s the other direction: not a key too small to notice a change, but a key too big to tell noise from intent.
The all-routes camera re-fits the map around every workout. Its update function did that on every call:
func updateWorkouts(_ workouts: [WorkoutData]) {
allWorkouts = workouts
if showAllRoutes {
updateCameraForAllRoutes()
}
}
The caller is .onChange(of: viewModel?.workoutsData) in MainWorkoutView. Under the id-only ==, that observer never fired on enrichment. Under the honest one, every enrichment merge fires it, so with all routes showing, a city name landing on one workout would re-frame the whole map. The fix asks whether the set of workouts changed, which is the only thing a fit-everything camera cares about:
func updateWorkouts(_ workouts: [WorkoutData]) {
let membershipChanged = allWorkouts.map(\.id) != workouts.map(\.id)
allWorkouts = workouts
if showAllRoutes && membershipChanged {
updateCameraForAllRoutes()
}
}
The test sets mapCameraPosition to a sentinel, .userLocation(fallback: .automatic), calls updateWorkouts with enriched copies of the same workouts, and expects the sentinel to still be there. A companion test checks that a real membership change does re-fit. A no-op is only worth something if you’ve also proven the op still happens.
The iPhone camera lives in SimplifiedEnhancedMapCameraModifier, a ViewModifier. Here’s the part that makes this whole post hang together:
.onChange(of: workout) { oldWorkout, newWorkout in
if oldWorkout?.id != newWorkout?.id {
camera.resetForNewWorkout()
}
updateCamera()
}
Look at what’s already there. The closure checks id before resetting. The code already knew identity mattered. But updateCamera() runs unconditionally, on every change of workout. Before the == fix, that was harmless, and only by accident: the id-only == meant “every change of workout” was really “every change of selection.” The loose == had been hiding the broad trigger. The moment == got honest, every city name, heart-rate summary, and heat-map series became a reason to refocus the camera.
Fixing bug one surfaced bug two.
The camera cares about two things: which workout it’s showing, and whether that workout’s route has arrived. So the key names exactly those:
.onChange(of: focusKey) { oldKey, newKey in
if oldKey.workoutID != newKey.workoutID {
camera.resetForNewWorkout()
}
updateCamera()
}
private struct FocusKey: Equatable {
let workoutID: UUID?
let routePointCount: Int
}
private var focusKey: FocusKey {
FocusKey(workoutID: workout?.id, routePointCount: workout?.routeCoordinates.count ?? 0)
}
FocusKey is the same trade == made, for the same reason: routes arrive whole, so a count is enough to mean “the route showed up.” But now it’s scoped to one observer instead of baked into the model. The view model already had a smaller version of this pattern, .onChange(of: viewModel.selectedWorkoutRouteCount(for: uiModel.selectedWorkout?.id)), which projects an Int instead of observing a model. FocusKey is that idea with a name and a second field.
All Within My Hands — Key Each Observer on the Effect It Drives
Both bugs are the same bug. The observer’s key didn’t match the effect it drives. In the first, the key was too small, so real changes were ignored. In the second, it was too big, so noise looked like intent.
That splits cleanly into two kinds of observers.
Rendering and Observation need a == that changes whenever anything shown changes. That’s the model’s job. It doesn’t have to be exhaustive, but it has to be sufficient, and with @Observable there’s no second chance: a write that == calls equal never notifies anyone.
Side effects should key on the smallest value that means intent changed. That covers onChange, task(id:), animation(_:value:), camera moves, and loads. Usually it’s an id, a list of ids, or a small named key struct like FocusKey. Usually it’s not a model.
When the camera starts jumping, the tempting fix is to loosen the model’s == until the noise goes away. Don’t. That just recreates bug one. And with Observation it’s worse than it used to be, because the stale data won’t even look stale all the time. It’ll show up on the next unrelated re-render, then refuse to reproduce when you go looking for it. A model’s == has one job, and that job is being true. When one observer needs a narrower view, give that observer its own key.
The End of the Line — A Pre-Ship Checklist
Everything on this list is something these three commits actually touched.
Find every hand-written == in your models:
grep -rn 'static func ==' --include='*.swift' .
For each one, ask whether it changes when anything a view shows changes. An id-only == on a type that gets mutated after creation is bug one, waiting for a whole-value assignment.
Find every .onChange(of:) that observes a whole model. Ask what should actually trigger it. If the answer is “a new selection,” key on the id. If the answer is two things, name them in a key struct.
Find every Set or contains on a type with a custom ==. If a caller means “the same workout,” it has to say .id, because once == is honest, contains won’t say it for you.
Read your tests for assertions that two differently loaded copies of the same thing are equal. Mine had one, with a comment explaining why that was fine.
The == I shipped is still hand-written, still compares routes by count, and still has a blind spot I can name. It tells observers the truth about the data they draw. The cameras only move when the thing they point at changes. I’ll take that trade.
Keep shipping.