Skip to content

fix: make component hydration synchronous#401

Merged
mohamedmansour merged 5 commits into
mainfrom
mohamedmansour-component-hydration-lifecycle
Jul 23, 2026
Merged

fix: make component hydration synchronous#401
mohamedmansour merged 5 commits into
mainfrom
mohamedmansour-component-hydration-lifecycle

Conversation

@mohamedmansour

@mohamedmansour mohamedmansour commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

super.connectedCallback() could return before hydration because the runtime deferred mounting whenever the whole document was still loading, even when the component's SSR markup was already fully parsed.

This change removes that unnecessary document-ready queue and makes the base callback the synchronous boundary for wiring bindings, events, and w-ref references. It also documents the required DOM-before-JS loading contract and adds regression coverage for a classic script running while document.readyState is still loading.

The documented contract precisely covers parser-inserted non-async modules, deferred classic scripts, blocking scripts placed after every instance they may upgrade, and the requirement that descendants not structurally mutate a containing component's SSR subtree before hydration.

Testing

  • cargo xtask check
  • pnpm test:unit and pnpm test:e2e in packages/webui-framework
  • cargo xtask e2e: the changed framework suite and 9 other suites passed; contact-book-manager and commerce reported only screenshot baseline differences

Partially addresses #393

Remove the document-ready deferral so the base connected callback returns only after component bindings, events, and references are wired.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 23, 2026 20:52

Copilot AI left a comment

Copy link
Copy Markdown

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 makes framework hydration synchronous by removing the document-ready deferral during TemplateElement.connectedCallback(), ensuring super.connectedCallback() is the deterministic boundary where bindings, events, and w-ref references are wired (when components are loaded under the documented “DOM-before-JS” contract).

Changes:

  • Remove the DOMContentLoaded-queued mount path so connectedCallback() mounts/hydrates immediately.
  • Add a Playwright regression test covering classic-script execution while document.readyState === "loading" and asserting refs are ready immediately after super.connectedCallback().
  • Document the required loading contract across the framework README, user docs, AI docs, and DESIGN.md.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/webui-framework/src/template-element.ts Removes DOM-ready queue; mounts synchronously during connectedCallback() to make super.connectedCallback() the hydration boundary.
packages/webui-framework/tests/fixtures/hydration-mismatch/webui.config.json Switches fixture back to classic script timing (enables readyState: "loading" coverage).
packages/webui-framework/tests/fixtures/hydration-mismatch/src/mismatch-after-super/mismatch-after-super.html Adds a w-ref used to assert refs are wired synchronously after super.connectedCallback().
packages/webui-framework/tests/fixtures/hydration-mismatch/hydration-mismatch.spec.ts Adds regression asserting synchronous hydration/ref wiring even while the document is still loading.
packages/webui-framework/tests/fixtures/hydration-mismatch/element.ts Captures document.readyState at connect and validates w-ref availability immediately after super.connectedCallback().
packages/webui-framework/README.md Documents super.connectedCallback() as the synchronous hydration boundary and the required loading contract.
docs/guide/concepts/interactivity.md Adds user-facing guidance on hydration boundary semantics and the loading contract.
docs/guide/concepts/hydration.md Documents the DOM-before-JS loading contract and synchronous hydration behavior.
docs/ai.md Updates AI reference to reflect synchronous hydration boundary semantics and loading contract.
DESIGN.md Updates the spec to codify the loading contract and synchronous hydration behavior for authored components.

Comment thread packages/webui-framework/src/template-element.ts Outdated
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 23, 2026 21:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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

@Qusic Qusic left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The synchronous super.connectedCallback() boundary looks useful and well-defined for setup that only depends on the current component's own bindings, events, and refs.

However, I think this addresses only one part of #393. A few lifecycle cases described there remain open:

1. A child can still mutate an ancestor before the ancestor hydrates

The attached self-removing-element repro is unchanged by this PR:

  • <perf-mark> is a plain custom element defined in <head>.
  • It removes itself during HTML parsing.
  • <test-signin> is defined later by a deferred bundle.
  • By the time <test-signin> synchronously hydrates, its compiled child-node path has already shifted, so the sibling button's @click is still not wired.

A synchronous boundary for <test-signin> cannot restore DOM structure that was changed before its hydration began. More generally, a child finishing its own callback does not imply that its parent or ancestor has hydrated.

A contract-only resolution is also reasonable, but it should be stated explicitly: descendants must not structurally mutate SSR DOM until the containing WebUI component has hydrated. Under that interpretation, the attached repro becomes unsupported rather than fixed. The remaining limitation is that an ordinary descendant currently has no public/queryable way to know when its containing WebUI component has reached that boundary.

2. Parent-to-child binding completion is still not observable

#324 already performs the child write after its own super.connectedCallback():

connectedCallback() {
  super.connectedCallback();
  this.value = "set-by-child";
}

The parent then resumes its binding pass and overwrites that value. This PR guarantees that the child's own hydration has completed, but it does not provide a boundary for the parent's later writes to that child.

That means code still has to defer outside the child's lifecycle to avoid this ordering, which is one of the timing gaps raised by #393.

3. The global completion signal still fires before all components hydrate

webui:hydration-complete still uses a per-mount hydrationStart() / hydrationEnd() counter. Synchronous custom-element upgrades happen sequentially, so the counter can go:

first component: 0 → 1 → 0 → dispatch and lock completion
second component: 0 → 1 → 0
...

The event therefore fires after the first component, while later component definitions/upgrades have not completed. It may now also dispatch from inside the first component's super.connectedCallback().

This is the P2 issue called out in #393 and remains independently useful even with a synchronous per-component boundary.

4. The new loading contract narrows previously tolerated behavior

Removing the DOM-ready fallback makes definition-before-markup ordering silently unsafe. For example:

<my-component>first</my-component>
<script src="components.js"></script>
<my-component>second</my-component>

The first instance has complete markup when upgraded, but the second can connect as soon as its opening tag is parsed, before its subtree exists.

The documentation should therefore say that a classic script must come after all SSR instances it may upgrade, and that the automatic guarantee applies specifically to a parser-inserted, non-async module script. A classic defer script is also a safe option.

If these cross-component cases are intentionally outside this PR's scope, could we change Closes #393 to something like “addresses the same-component lifecycle portion of #393” and keep the remaining cases open, or split them into explicit follow-up issues?

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 23, 2026 21:43
@mohamedmansour

Copy link
Copy Markdown
Contributor Author

@Qusic Thanks, this review is useful. I accepted points 1, 3, and 4: the contract now explicitly prohibits structural SSR mutation before containing-component hydration, precisely lists safe script ordering, and the PR now only partially addresses #393. The queryable ancestor boundary and correct global completion remain tracked by #393.

Point 2 is already resolved by #324 in the current CSR staging path: initial parent bindings are applied while children are detached, before their connectedCallback(), and the client-binding-lifecycle regressions cover fallback preservation and initial parent-value visibility. I therefore did not change that path in this PR.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 23, 2026 21:56
@mohamedmansour

Copy link
Copy Markdown
Contributor Author

@Qusic Follow-up: point 3 is now fixed in this PR. The one-shot global event waits until initial parsing is complete and the active hydration count is zero, with regression coverage proving all sequentially upgraded components are ready when it fires. #393 remains open for the queryable ancestor/per-instance boundary discussed in point 1.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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

Keep global completion semantics out of this PR because the runtime cannot infer dynamic import boundaries.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 23, 2026 22:15
@mohamedmansour

Copy link
Copy Markdown
Contributor Author

@Qusic Correction after further design review: I reverted the point 3 change. DOMContentLoaded cannot reliably represent completion when dynamic imports are involved, so aggregate completion remains open in #393 for an explicit entry-module or bundler boundary. This PR remains scoped to synchronous same-component hydration and its loading contract.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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

@mohamedmansour
mohamedmansour merged commit 6d89292 into main Jul 23, 2026
22 checks passed
@mohamedmansour
mohamedmansour deleted the mohamedmansour-component-hydration-lifecycle branch July 23, 2026 23:04
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