Skip to content

fix: preserve parent state across delayed child upgrades - #438

Merged
Mohamed Mansour (mohamedmansour) merged 1 commit into
microsoft:mainfrom
mohamedmansour:mohamedmansour/fix-pending-parent-state
Aug 13, 2026
Merged

fix: preserve parent state across delayed child upgrades#438
Mohamed Mansour (mohamedmansour) merged 1 commit into
microsoft:mainfrom
mohamedmansour:mohamedmansour/fix-pending-parent-state

Conversation

@mohamedmansour

Copy link
Copy Markdown
Contributor

Why this is needed

Complex : bindings pass structured JavaScript values such as arrays and objects from a parent template into a child component. Unlike text and scalar attributes, those values do not have a durable HTML representation.

That creates a definition-order gap during SSR hydration:

  1. The server renders the child DOM from the parent state.
  2. The browser can upgrade and hydrate the parent before the child class is defined.
  3. The parent must transfer the complex value before the child walks its own bindings.
  4. Directly assigning the value to an unresolved custom element creates an own property.
  5. When the child later upgrades, that own property shadows the @observable accessor on the class prototype. The reactive setter never receives the parent value, and a field initializer can replace it with the default.

This is especially visible when the parent and child use different state names, for example :items="{{sourceItems}}". Page-wide bootstrap state cannot seed the child's items field from the parent's sourceItems key, even though the SSR DOM was rendered correctly.

The result is a component whose rendered SSR content and JavaScript state disagree. Repeat and conditional bindings can retain stale state, future writes can bypass the accessor, and component behavior depends on whether the parent or child happened to be defined first.

What changed

  • Prime known complex properties during the existing SSR attribute-binding pass, preserving single-pass hydration.
  • Keep the existing allocation-free branded state hook for children that are already upgraded.
  • Retain values for an unresolved compiled WebUI child in a module-local WeakMap<Element, PendingParentState>.
  • Apply the pending parent values after the child's own bootstrap state and before its first binding walk, for both SSR and client-created mounts.
  • Preserve post-SSR parent updates with a lazily allocated root-name set and transfer that set into the child's existing deferred replay path without allocating a duplicate.
  • Preserve normal direct-property assignment for undefined third-party custom elements.
  • Document the contract only in DESIGN.md and the framework maintainer rendering notes. This is a transparent runtime fix, so no user-facing guide changes are included.

Why this design

The first version considered for this fix stored three Symbol.for(...) values on every pending child and registered one customElements.whenDefined() continuation per element. That approach was not suitable for the framework runtime:

  • each unresolved definition promise strongly retained its element until that tag was defined;
  • a tag that was never defined retained removed elements indefinitely;
  • Symbol.for(...) created a cross-runtime global protocol for private state;
  • a class may be registered while a detached element is still unupgraded, so customElements.get() alone does not make direct assignment accessor-safe;
  • the generic waiter changed semantics for third-party elements even though the defect is specific to compiled WebUI children.

The weak handoff keeps the normal upgraded-child path unchanged and allocation-free. Only a child that actually receives parent state before upgrade allocates one weak entry and one null-prototype value record. The replay Set is allocated only when a newer parent value must be reconciled after SSR wiring. There are no definition promises, retained-element sets, per-tag registries, or global pending-state bridges.

Regression coverage

The framework tests now cover:

  • child-first SSR hydration;
  • parent-first SSR hydration;
  • different parent and child property names;
  • a defined but detached child that has not upgraded yet;
  • a parent update queued before delayed child definition;
  • replay of the newest value after initial SSR wiring;
  • preservation of the child's prototype accessor with no shadowing own property;
  • child repeat and conditional bindings driven by complex values;
  • the existing non-observable authored-setter fallback;
  • unchanged direct assignment for unresolved third-party elements.

Performance and memory

The baseline below is the initial per-element whenDefined() pending-state implementation evaluated for this fix. The result is the weak handoff in this PR.

Hydration timings are medians from 7 serial Playwright runs. Bundle size is a minified browser ESM build with __WEBUI_DEV__=false. Retention was measured with a 2,000-element Chromium CDP stress probe followed by forced garbage collection.

Metric Before After Delta
Wide hydration, 150 instances 10.0 ms 9.0 ms -10.0%
Deep hydration, 150 instances 2.9 ms 2.4 ms -17.2%
Nested hydration, 40 instances 2.3 ms 2.1 ms -8.7%
Production bundle 48,471 bytes 48,006 bytes -465 bytes (-0.96%)
Unresolved elements retained after forced GC 2,000 / 2,000 0 / 2,000 -100%

The timing results show no hydration regression; the deterministic bundle and retention results confirm the lower code and memory cost.

Validation

  • Full @microsoft/webui-framework unit and Playwright suites
  • Focused complex-property and optional-state browser regressions
  • Framework TypeScript and E2E type checking
  • Documentation build
  • cargo xtask check

Use a weak WebUI-only handoff for complex SSR properties so delayed children receive parent state without accessor shadowing or retained definition promises.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 79ab856c-34d8-40c0-9d0d-7d21125739bb

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an SSR hydration edge case where complex :-bound values (arrays/objects) assigned from a parent to a child before the child’s custom element upgrades can accidentally create shadowing own-properties, preventing the child’s @observable accessors from receiving the intended value. The implementation preserves parent-provided state for unupgraded compiled WebUI children via a module-local WeakMap and applies it at the correct point in the child lifecycle.

Changes:

  • Prime known complex : properties during the existing SSR attribute-binding pass, and route all complex-property writes through a unified writer that is accessor-safe for unupgraded compiled WebUI children.
  • Queue pending parent-supplied complex values (and post-SSR updates that must replay) for unupgraded compiled WebUI children, then apply them after bootstrap state but before the child’s first binding walk.
  • Expand fixture and unit/Playwright coverage to validate parent-first/child-first definition order, renamed parent state keys, delayed definitions, detached-yet-defined scenarios, and post-SSR parent updates.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/webui-framework/src/template-element.ts Adds pending-parent-state queueing/apply path and primes known complex : bindings during SSR hydration without introducing retained-element promises.
packages/webui-framework/src/template-element.test.ts Adds unit tests ensuring queued complex properties do not create shadowing own-properties and preserves direct assignment for third-party elements.
packages/webui-framework/tests/fixtures/complex-prop/complex-prop.spec.ts Adds Playwright regression coverage for definition-order permutations and delayed custom-element upgrade behavior.
packages/webui-framework/tests/fixtures/complex-prop/element.ts Adjusts fixture element definitions to simulate parent-first hydration, detached-defined scenarios, and delayed child definition.
packages/webui-framework/tests/fixtures/complex-prop/src/test-item-host/test-item-host.html Updates complex binding to use renamed parent property and adds the delayed child instance.
packages/webui-framework/tests/fixtures/complex-prop/src/test-delayed-prop-child/test-delayed-prop-child.html Introduces delayed-child template that renders a <for> loop over a complex items array.
packages/webui-framework/tests/fixtures/complex-prop/state.json Updates fixture bootstrap state keys (sourceItems, delayedItems) to exercise renamed and delayed paths.
packages/webui-framework/RENDERING.md Documents the SSR attribute pass priming of known complex properties and weak pending-state handoff behavior.
DESIGN.md Specifies the contract for complex : property hydration, unupgraded child handling, replay behavior, and third-party semantics.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@mohamedmansour
Mohamed Mansour (mohamedmansour) merged commit 213b4a4 into microsoft:main Aug 13, 2026
10 checks passed
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.

3 participants