Skip to content

Support @Observable view models#1

Merged
colemancda merged 90 commits into
masterfrom
feature/Observable
Jul 15, 2026
Merged

Support @Observable view models#1
colemancda merged 90 commits into
masterfrom
feature/Observable

Conversation

@colemancda

Copy link
Copy Markdown
Member

Summary

  • Auto-tracks @Observable properties read during View.body by wrapping body evaluation in withObservationTracking, routing changes through the existing queueUpdate re-render path (same mechanism @State/ObservableObject already use, no parallel scheduling system)
  • Bumps deployment target to iOS 17 (required by the Observation framework)
  • Fixes 13 Swift 6 concurrency gaps in the prior migration that a fully-clean rebuild (wiped DerivedData/module cache) surfaced — Xcode's incremental build state had been stale throughout that work, masking real failures behind cached success
  • Adds a demo @Observable view model to CarInformationView for side-by-side comparison against the existing @State counter

Test plan

  • Clean build (xcodebuild clean + wipe DerivedData) shows only the pre-existing, unrelated CGSize/Animatable conformance-ambiguity baseline errors (confirmed identical on master before this branch)
  • Run the CarPlay demo, navigate to the Information screen, tap "Increment (Observable)" repeatedly, confirm the row updates live
  • Confirm the pre-existing @State counter/"Increment" button still works unaffected
  • Push forward via "Next" NavigationLink, confirm the new screen's observable counter starts at 0 while the previous screen's value is preserved on popping back

A fully clean rebuild (wiped DerivedData/module cache, not just
xcodebuild clean) surfaced this among several concurrency errors the
prior Swift 6 migration's verification builds never caught, since
Xcode's incremental build state was stale throughout that work and
silently hid real failures behind cached success.

These inits/statics default to EnvironmentValues.defaultEnvironment,
which is @mainactor, so they need @mainactor themselves.
_AnimationModifier and its private ContentWrapper both conform to
Equatable alongside ViewModifier/View, so their == witnesses need to
be nonisolated to match Equatable's (non-isolated) requirement despite
the enclosing MainActor-inferred type. Their stored properties
(animation, value, lastValue) get nonisolated(unsafe) since Value is
an unconstrained generic with no Sendable guarantee.
AnyLayoutBox is @mainactor to match Layout, but its animatableData
requirement mirrors Animatable's (non-isolated) requirement, so it
needs a per-requirement nonisolated override - same shape as the
AnyShapeBox fix from the original migration, just missed for the
Fiber layout system's equivalent type.
Marks animatableData and the opacity property it reads nonisolated,
matching Animatable's non-isolated requirement despite _OpacityEffect
also conforming to the MainActor-inferred ViewModifier.
Same fix as _OpacityEffect: animatableData and the angle/anchor
properties it reads need nonisolated(unsafe) to match Animatable's
non-isolated requirement.
Element conforms to View and Identifiable in the same declaration, so
whole-type isolation inference made its id witness main-actor-isolated
even though Identifiable's requirement isn't. nonisolated(unsafe)
resolves it, matching the same pattern used for Text/Image's Equatable
witnesses.
Same pattern as other @State properties fixed during the original
migration (TaskModifier, DisclosureGroup, etc.): the property
wrapper's isolation inference can't resolve a plain mutable stored
property without an explicit nonisolated(unsafe).
Same @State/@binding property pattern as ButtonBase's isPressed -
nonisolated(unsafe) needed since SelectionValue is an unconstrained
generic with no Sendable guarantee.
Same @binding property pattern as Picker's selection.
Image conforms to _PrimitiveView and Equatable directly in its
primary declaration, the same shape that required marking Text
nonisolated during the original migration - whole-type isolation
inference was making its == witness main-actor-isolated even though
Equatable's requirement isn't. Marks Image nonisolated at the struct
level and its @State _intrinsicSize nonisolated(unsafe).
Same @State property pattern as ButtonBase's isPressed.
NavigationContext is a plain ObservableObject class, not Sendable, so
the @StateObject property wrapper's isolation inference can't resolve
it without an explicit nonisolated(unsafe).
ToolbarItem conditionally conforms to Identifiable via its plain id
stored property; nonisolated(unsafe) is needed since ID is an
unconstrained generic with no Sendable guarantee.
@Observable/Observation requires iOS 17+. No @available gating is
introduced for it; the whole package now targets iOS 17 as its floor.
Wraps the call that evaluates a View's real body (the bodyClosure(view)
branch of render(compositeView:), which invokes the user's View.body
computed property) in withObservationTracking. Any @observable property
read synchronously during that evaluation gets tracked; mutating it
later re-triggers the exact same re-render path @State and
ObservableObject already use - onChange calls the existing
queueUpdate(for:transaction:), so it shares the same Set<Rerender>
dedup and scheduler hop, no parallel scheduling system.

render(compositeView:) already re-runs on every mount and re-render,
so a fresh withObservationTracking call is naturally re-established
each time - no manual re-registration loop needed, matching the same
"tear down and re-subscribe every render" shape the existing Combine
ObservedProperty path already uses for @ObservedObject/@StateObject/
@EnvironmentObject.

withObservationTracking's onChange is a plain non-isolated @sendable
closure, but StackReconciler and MountedCompositeElement are
MainActor-isolated and not Sendable, so they can't be captured
directly in a @sendable closure even weakly. WeakBox<T: AnyObject>:
@unchecked Sendable holds the weak references across that boundary;
onChange hops back onto the main actor via Task { @mainactor in } to
dereference them before calling queueUpdate.

No changes needed to State (it's unconstrained on Value, so it already
preserves an @observable class instance's identity across re-renders
via its existing storage/getter mechanism) or to the DynamicProperty
reflection layer (@observable view models are plain stored properties
on classes, not property-wrapper structs, so they're never discovered
via - or need to be discovered via - the reflection-based
DynamicProperty walk).
Adds InformationViewModel (@observable) alongside the existing @State
counter, with its own increment button and detail row, as a
side-by-side comparison verifying the new auto-tracking works without
disturbing the pre-existing @State/@observableobject re-render path.
@colemancda
colemancda merged commit 703848a into master Jul 15, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant