Skip to content

Fix unanchored optional/container child on same-id replace; expose modelID#26

Merged
mansbernhardt merged 1 commit into
mainfrom
claude/trusting-yalow-955abf
Jun 20, 2026
Merged

Fix unanchored optional/container child on same-id replace; expose modelID#26
mansbernhardt merged 1 commit into
mainfrom
claude/trusting-yalow-955abf

Conversation

@mansbernhardt

Copy link
Copy Markdown
Collaborator

Summary

Investigating a field report (a SwiftUI @ObservedModel view stuck at a model's birth state, no observation, never recovering — for a @Model with an explicit, reusable Identifiable id) surfaced one genuine framework bug plus some related API/diagnostic/doc gaps.

Fixed — optional/container child left unanchored on same-id replace

For a @Model with an explicit, reusable id, replacing an optional @Model child (var child: M?) — or any non-collection ModelContainer-typed property — with a fresh instance sharing the existing child's id left the new child unanchored:

  • The ModelContainer write path had a fast path if containerIsSame(newValue, container) { container = newValue; return }. When the structure looked unchanged by .id, it stored the assigned value raw and skipped updateContext.
  • That is correct only when the assigned value is the already-anchored live child. For a fresh instance reusing a domain id, it stored a pre-anchor value with no context — the child dropped out of the hierarchy: no observation, no tasks, no onActivate, 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-element findOrTrackChild fast path. Only same-id replacement was affected; different-id and nil→value already anchored correctly.

Added

  • public var Model.modelID: ModelID — the stable, per-instance identity, distinct from the (possibly domain-valued) Identifiable id. The reliable way to tell whether two model values refer to the same live instance. (Moved from the internal accessor; identical to id for models without an explicit id.)
  • DEBUG duplicate-id diagnostic — assigning a model collection with two distinct instances sharing an id now reportIssues (mirroring SwiftUI ForEach). Keyed by modelID, so legitimate sharing (the same instance appearing more than once) does not warn.

Docs / tests

  • Corrected the misleading "globally-unique ModelID / UUIDs" registry-key comments to describe the real same-.id stable-identity continuity contract.
  • Added ExplicitIdReplacementTests characterizing behavior across single / optional / collection child shapes.

A known semantic difference (intentionally not changed here)

Same-id replacement now behaves like this:

var child: M var child: M? var items: [M]
replace (new state) continuity (this PR) continuity

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-id element continuing the existing child. Unifying on "continuity everywhere" (bring single-child into line) is plausible but is a behavior change to the common model.child = X assignment 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

…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>
@mansbernhardt mansbernhardt merged commit e52efa6 into main Jun 20, 2026
6 checks passed
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>
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