Fix unanchored optional/container child on same-id replace; expose modelID#26
Merged
Merged
Conversation
…delID When a `@Model` declares an explicit, reusable Identifiable `id`, replacing an optional `@Model` child (or any non-collection `ModelContainer`-typed property) with a fresh instance sharing that `id` left the new child unanchored: the ModelContainer write fast path stored the pre-anchor value raw and skipped `updateContext`, so the child had no context — no observation, tasks, or onActivate, and reads returned its detached state. Remove the fast path so the path always reconciles, reusing the existing child's context (continuity, matching the `[Model]` collection write path). Only same-id replacement was affected; different-id and nil->value already anchored correctly. Also: - Expose `public var Model.modelID` — the stable per-instance identity, distinct from the (possibly domain-valued) Identifiable `id`. Moved from the internal accessor. - Add a DEBUG diagnostic when a model collection is assigned with two *distinct* instances sharing an `id` (conflation — keyed by modelID so legitimate sharing of the same instance does not warn), mirroring SwiftUI ForEach. - Correct the misleading "globally-unique ModelID/UUIDs" registry-key comments to describe the real same-`.id` stable-identity continuity contract. - Add ExplicitIdReplacementTests characterizing the behavior across single / optional / collection child shapes. Validated: full suite green on both serial and parallel (781 tests, only the documented known-issue flakes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jun 20, 2026
mansbernhardt
added a commit
that referenced
this pull request
Jun 22, 2026
…27) Characterises why a non-live @model value silently fails to drive a SwiftUI view — the mechanism behind the "sub-view stuck at a child's birth state" symptom whose framework fix (optional/container same-id unanchored child) landed in #26. - A child read out of a live parent is itself live (context != nil, lifetime == .active) and tracks subsequent mutations — the normal read path never hands a frozen copy to a sub-view. - A frozen copy (same modelID, no context) does NOT participate in Apple's withObservationTracking (the iOS 17+ registrar path SwiftUI uses), so a view holding one never re-renders. Hence a non-live value must never reach an @ObservedModel. - Also covers reading per-instance identity via the public `modelID` (added in #26) when an explicit domain `id` shadows Identifiable.id. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Investigating a field report (a SwiftUI
@ObservedModelview stuck at a model's birth state, no observation, never recovering — for a@Modelwith an explicit, reusable Identifiableid) surfaced one genuine framework bug plus some related API/diagnostic/doc gaps.Fixed — optional/container child left unanchored on same-
idreplaceFor a
@Modelwith an explicit, reusableid, replacing an optional@Modelchild (var child: M?) — or any non-collectionModelContainer-typed property — with a fresh instance sharing the existing child'sidleft the new child unanchored:ModelContainerwrite path had a fast pathif containerIsSame(newValue, container) { container = newValue; return }. When the structure looked unchanged by.id, it stored the assigned value raw and skippedupdateContext.id, it stored a pre-anchor value with no context — the child dropped out of the hierarchy: no observation, no tasks, noonActivate, reads returned its detached state. A view bound to it rendered birth state and never updated.Fix: remove the fast path so the write always reconciles, reusing the existing child's context (continuity — matching the
[Model]collection write path). Genuine no-op write-backs stay cheap via the per-elementfindOrTrackChildfast path. Only same-idreplacement was affected; different-idandnil→value already anchored correctly.Added
public var Model.modelID: ModelID— the stable, per-instance identity, distinct from the (possibly domain-valued)Identifiableid. The reliable way to tell whether two model values refer to the same live instance. (Moved from the internal accessor; identical toidfor models without an explicitid.)idnowreportIssues (mirroring SwiftUIForEach). Keyed bymodelID, so legitimate sharing (the same instance appearing more than once) does not warn.Docs / tests
.idstable-identity continuity contract.ExplicitIdReplacementTestscharacterizing behavior across single / optional / collection child shapes.A known semantic difference (intentionally not changed here)
Same-
idreplacement now behaves like this:var child: Mvar child: M?var items: [M]The single-child path still replaces while collection/optional continue. Unifying on "replace everywhere" is not viable — it breaks
ActivateTests.testChildrenCaseActivation, which depends on a fresh same-idelement continuing the existing child. Unifying on "continuity everywhere" (bring single-child into line) is plausible but is a behavior change to the commonmodel.child = Xassignment and deserves its own PR/decision. Left as a follow-up.Validation
Full suite green on both serial and parallel (781 tests; only the documented known-issue flakes). Release build clean.
🤖 Generated with Claude Code