fix: make component hydration synchronous#401
Conversation
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>
There was a problem hiding this comment.
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 soconnectedCallback()mounts/hydrates immediately. - Add a Playwright regression test covering classic-script execution while
document.readyState === "loading"and asserting refs are ready immediately aftersuper.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. |
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Qusic
left a comment
There was a problem hiding this comment.
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@clickis 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>
|
@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 |
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
@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. |
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>
|
@Qusic Correction after further design review: I reverted the point 3 change. |
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-refreferences. It also documents the required DOM-before-JS loading contract and adds regression coverage for a classic script running whiledocument.readyStateis stillloading.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 checkpnpm test:unitandpnpm test:e2einpackages/webui-frameworkcargo xtask e2e: the changed framework suite and 9 other suites passed; contact-book-manager and commerce reported only screenshot baseline differencesPartially addresses #393