Skip to content

Hydrate server-rendered markup instead of rebuilding it - #21

Merged
lannbot merged 1 commit into
mainfrom
feat/hydration
Sep 4, 2026
Merged

Hydrate server-rendered markup instead of rebuilding it#21
lannbot merged 1 commit into
mainfrom
feat/hydration

Conversation

@lannbot

@lannbot lannbot commented Sep 4, 2026

Copy link
Copy Markdown
Owner

The client renderer can now adopt HTML that the SSR artifact already
produced — binding the DOM nodes that are already there to element ids
instead of creating new ones.

polymorph:dioxus goes to 0.6.0: run takes a render-mode { fresh, hydrate } and operation gains hydrate(list<element-id>).

The split

dioxus splits hydration in two, and the split lands on our guest/host
boundary almost exactly:

does what never does
src/hydrate.rs (guest) walks its own template tree, produces the ordered element ids look at the DOM
applier.ts's hydrate (host) walks the DOM, binds those ids to the marked nodes look at the vdom

Neither side compares HTML against the vdom. dioxus-ssr's pre_render
numbers its markers with one monotonic counter, and the guest walk stops at
exactly those sites, so marker n and ids[n] are the nth stop of one walk
described twice. The initial rebuild still runs — that is what assigns the
ids — but through a MutationWriter with node-creating operations
suppressed.

Markers, for reference: data-node-hydration="n[,event:bubbles]" on
elements, <!--node-id n-->text<!--#--> around dynamic text,
<!--placeholder n-->.

Three deliberate divergences from dioxus-web

Listeners flow as ordinary new-event-listener operations, not parsed
out of the marker's ,click:1 suffix (which the host ignores). Ours is the
only form carrying the interned name id, and it is what makes the synthetic
mounted event and the observer-backed resize/visible families work
exactly as on a fresh mount. Consequently there is no to_mount vector and
no special onmounted path — both of which upstream needs.

The host validates: every marker index in range, each matched exactly
once, all matched. Upstream's unchecked ids[parseInt(...)] silently writes
nodes[undefined] on a stale marker. A mismatch is build skew, so it throws
through onError rather than falling back to a fresh render — a fallback
would hide the skew and double the document.

The host collects comment nodes before processing them rather than
mutating the DOM during a TreeWalker, which is what makes upstream's loop
hard to follow. Marker indices are self-describing, so visit order cannot
affect correctness.

ssr now always pre-renders

Markers are what make the markup adoptable, and they are inert in a page
that never hydrates. Nothing wanted a marker-free mode, so there isn't one.

Gates

Each adds one layer, and the cheap ones sit at the bottom:

cargo test       tests/hydration_order.rs — the walk emits exactly one id
                 per marker, across dioxus-ssr's own hydration corpus
                 (10 apps: roots, dynamic attrs, listeners, dynamic text,
                 placeholders, child components, fragments, and a tree with
                 no element marker at all). Natively; no DOM, no wasm.
deno task test   hydrate_test.ts unit-tests the DOM walk against
                 hand-written markers; hydrate_component_test.ts hydrates
                 the REAL counter component against the REAL prerendered
                 golden.
just e2e         the same in Chromium, on a page whose markup arrived
                 server-rendered, with every server node stamped before any
                 component code runs.

Correspondence — that id n really is the node numbered n — only has
meaning where a real component's ids meet a real component's HTML, and
node identity is what proves it: hydration that quietly re-rendered
would pass every text assertion while failing every assertStrictEquals.
The browser lane does the same thing with a data-server-rendered stamp
applied before the module script boots.

Verified the check bites: permuting two marker indices in the golden makes
the component test fail (waitFor timed out: count increments after click
— clicking #inc decremented). Restored afterwards.

tests/hydration_order.rs is scoped to counts by design and says so;
positional correctness is a DOM property and belongs to the host tests.

Out of scope

Suspense, streaming hydration, server-data transport, and serving a full
page (shell plus client bundle) from the wasi:http artifact. The serve
component still returns the fragment only, so the hydration lane runs
through the SSG artifact and the existing harness.

The client renderer can now adopt HTML that the SSR artifact already
produced, binding the existing DOM nodes to element ids rather than
creating new ones. `polymorph:dioxus` goes to 0.6.0: `run` takes a
`render-mode { fresh, hydrate }` and `operation` gains
`hydrate(list<element-id>)`.

The split follows dioxus's own, and lands on the guest/host boundary:

- Guest (`src/hydrate.rs`) walks its template tree and produces the
  ordered element ids. It never looks at the DOM.
- Host (`applier.ts`'s `hydrate`) walks the DOM and binds them:
  `data-node-hydration` attributes, `<!--node-id N-->text<!--#-->` and
  `<!--placeholder N-->`.

Neither side compares HTML against the vdom. `dioxus-ssr`'s `pre_render`
numbers its markers with one monotonic counter, and the guest walk stops
at exactly those sites, so marker n and ids[n] are the nth stop of one
walk described twice. The initial `rebuild` still runs — that is what
assigns the ids — but through a `MutationWriter` with node-creating
operations suppressed.

Three divergences from dioxus-web, all deliberate:

Listeners flow as ordinary `new-event-listener` operations rather than
being parsed out of the marker's `,click:1` suffix. Ours is the only
form carrying the interned name id, and it is what makes the synthetic
`mounted` event and the observer-backed `resize`/`visible` families work
exactly as on a fresh mount. So there is no `to_mount` vector and no
special `onmounted` path.

The host validates: every marker index in range, each matched exactly
once, all matched. Upstream's unchecked `ids[parseInt(...)]` silently
binds `nodes[undefined]` on a stale marker. A mismatch is build skew, so
it throws through `onError` rather than falling back to a fresh render,
which would hide the skew and double the document.

The host collects comment nodes before processing them instead of
mutating the DOM during a TreeWalker, which is what makes upstream's
loop hard to follow. Marker indices are self-describing, so visit order
cannot matter.

`ssr`'s `render_to` now always pre-renders. Markers are what make the
markup adoptable and are inert otherwise; nothing wanted a marker-free
mode.

Gates, each adding one layer:

  cargo test         tests/hydration_order.rs — the walk emits one id per
                     marker across dioxus-ssr's own hydration corpus,
                     natively, with no DOM
  deno task test     hydrate_test.ts unit-tests the DOM walk;
                     hydrate_component_test.ts hydrates the real counter
                     component against the real prerendered golden
  just e2e           the same in Chromium, against a page whose markup
                     arrived server-rendered

Correspondence — that id n really is the node numbered n — only has
meaning where a real component's ids meet a real component's HTML, and
node identity is what proves it: hydration that quietly re-rendered
would pass every text assertion. Verified the check bites by permuting
two marker indices in the golden, which fails the component test.

Suspense, streaming hydration, server-data transport and serving a full
page (shell plus client bundle) from the wasi:http artifact remain out
of scope.
@lannbot
lannbot enabled auto-merge September 4, 2026 16:23
@lannbot
lannbot merged commit 7c1f526 into main Sep 4, 2026
1 check 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.

2 participants