From 8163a3a13f159076bbe08ffe3a85e3a6d2c3d711 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Fri, 4 Sep 2026 12:23:24 -0400 Subject: [PATCH] Hydrate server-rendered markup instead of rebuilding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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)`. 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, `text` and ``. 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. --- Cargo.lock | 1 + Cargo.toml | 3 + e2e/server.ts | 41 +++++ e2e/tests/hydrate.spec.ts | 111 +++++++++++++ examples/counter/golden.html | 2 +- fixtures/surface-probe/src/lib.rs | 5 +- harness/entry.ts | 17 +- host/src/applier.ts | 138 ++++++++++++++++ host/src/host.ts | 26 ++- host/src/operations.ts | 6 +- host/tests/hydrate_component_test.ts | 233 +++++++++++++++++++++++++++ host/tests/hydrate_test.ts | 193 ++++++++++++++++++++++ host/tests/operations_test.ts | 3 + justfile | 20 ++- src/driver.rs | 45 +++++- src/hydrate.rs | 173 ++++++++++++++++++++ src/lib.rs | 8 +- src/writer.rs | 95 ++++++++++- ssr/src/lib.rs | 15 +- ssr/tests/render.rs | 16 +- tests/hydration_order.rs | 224 +++++++++++++++++++++++++ wit/world.wit | 56 ++++++- 22 files changed, 1402 insertions(+), 29 deletions(-) create mode 100644 e2e/tests/hydrate.spec.ts create mode 100644 host/tests/hydrate_component_test.ts create mode 100644 host/tests/hydrate_test.ts create mode 100644 src/hydrate.rs create mode 100644 tests/hydration_order.rs diff --git a/Cargo.lock b/Cargo.lock index a85a843..13aedd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1556,6 +1556,7 @@ dependencies = [ "dioxus-core", "dioxus-core-types", "dioxus-html", + "dioxus-ssr", "rustc-hash 2.1.3", "wit-bindgen 0.60.0", ] diff --git a/Cargo.toml b/Cargo.toml index 3e3eb7c..1f1bd0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,9 @@ dioxus = { version = "=0.7.10", default-features = false, features = [ "signals", "hooks", ] } +# `pre_render`'s hydration markers are the order `src/hydrate.rs` must +# reproduce; tests/hydration_order.rs checks the two against each other. +dioxus-ssr = "=0.7.9" [workspace] members = [".", "ssr", "fixtures/surface-probe", "examples/counter", "examples/bench-rows", "examples/todomvc", "examples/components", "examples/primitives"] diff --git a/e2e/server.ts b/e2e/server.ts index 24691b9..f72f984 100644 --- a/e2e/server.ts +++ b/e2e/server.ts @@ -39,11 +39,52 @@ async function serveFile(path: string): Promise { } } +/** + * `/hydrate.html?app=` — the same harness page, but with `#app` + * already holding the app's prerendered markup, as a server would have sent + * it. Synthesized here rather than written to disk: it is index.html plus + * one file's contents, and a generated page checked into harness/ would + * immediately go stale against `just ssg-example`. + * + * Two inline classic scripts ride along. `window.__HYDRATE` tells entry.ts + * to ask for `render-mode.hydrate`; the stamping loop marks every + * server-rendered element so the spec can prove those exact nodes survived + * — the browser-side equivalent of host/tests/hydrate_component_test.ts's + * identity assertions, which is the only thing that distinguishes hydration + * from a re-render that happens to look the same. Classic scripts run + * before the deferred module script that boots the app, so the stamps are + * in place before anything hydrates. + */ +async function serveHydratePage(app: string): Promise { + const [shell, markup] = await Promise.all([ + Deno.readTextFile(join(harnessDir, "index.html")), + Deno.readTextFile(join(repoRoot, "examples", app, "golden.html")), + ]); + const injected = `
${markup}
+ `; + const html = shell.replace('
', injected); + if (html === shell) throw new Error("harness/index.html no longer contains
"); + return new Response(html, { headers: { "content-type": CONTENT_TYPES[".html"] } }); +} + function handler(req: Request): Promise { const url = new URL(req.url); let pathname = url.pathname; if (pathname === "/") pathname = "/index.html"; + if (pathname === "/hydrate.html") { + const app = url.searchParams.get("app") ?? "counter"; + if (!KNOWN_APPS.includes(app)) { + return Promise.resolve(new Response(`unknown app: ${app}`, { status: 404 })); + } + return serveHydratePage(app); + } + if (pathname.endsWith(".component.wasm")) { const name = pathname.slice(1, -".component.wasm".length); if (KNOWN_APPS.includes(name)) { diff --git a/e2e/tests/hydrate.spec.ts b/e2e/tests/hydrate.spec.ts new file mode 100644 index 0000000..01bebfa --- /dev/null +++ b/e2e/tests/hydrate.spec.ts @@ -0,0 +1,111 @@ +// Real-browser (Chromium via Playwright) E2E test for HYDRATION of the +// counter example: the page arrives with the app's markup already in it and +// the client adopts those exact nodes. +// +// Governing docs / authorities: +// - host/tests/hydrate_component_test.ts: authoritative for what +// hydration must preserve (mirrors it, but drives real user input and a +// real browser instead of linkedom + `mounted.dispatch(...)`). +// - wit/world.wit, `interface mutations`' `hydrate` type and world `app`'s +// `render-mode`: the contract. +// - e2e/server.ts `serveHydratePage`: synthesizes `/hydrate.html` and +// stamps every server-rendered element with `data-server-rendered`. +// - examples/counter/src/lib.rs: authoritative for element ids/structure. +// +// The stamp is the whole point. Hydration that silently re-rendered would +// produce a visually identical page that passes every text assertion — and +// fail here, because the replacement nodes carry no stamp. +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "@playwright/test"; + +const repoRoot = join(new URL(".", import.meta.url).pathname, "..", ".."); + +function hydrateUrl(): string { + const url = process.env.E2E_BASE_URL; + if (!url) throw new Error("E2E_BASE_URL not set — did global-setup.ts run?"); + return new URL("/hydrate.html?app=counter", url).toString(); +} + +test.beforeEach(() => { + for ( + const [path, recipe] of [ + ["examples/build/counter.component.wasm", "just example counter"], + ["examples/counter/golden.html", "just ssg-example counter"], + ] as const + ) { + if (!existsSync(join(repoRoot, path))) { + throw new Error(`${path} missing — run \`${recipe}\` first (\`just e2e\` does this for you).`); + } + } +}); + +test("counter example: hydrates server-rendered markup in Chromium", async ({ page }) => { + const consoleErrors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") consoleErrors.push(msg.text()); + }); + const pageErrors: string[] = []; + page.on("pageerror", (err) => pageErrors.push(err.stack ?? err.message)); + + await page.goto(hydrateUrl()); + + // Before the client boots, the markup is already the finished page — this + // is what a user sees with JS still downloading, and it is the reason to + // prerender at all. + await expect(page.locator("#count")).toHaveText("0"); + await expect(page.locator("#parity")).toHaveText("count is 0"); + await expect(page.locator("#items li")).toHaveText(["alpha", "beta"]); + + await page.waitForFunction(() => (globalThis as unknown as { __mounted?: boolean }).__mounted === true); + expect(await page.evaluate(() => (globalThis as unknown as { __mountFailed?: boolean }).__mountFailed)).toBeFalsy(); + + // The server's marker comments are consumed by the hydrate operation. + expect(await page.evaluate(() => document.getElementById("app")!.innerHTML)).not.toContain("node-id"); + + // Adoption, not re-creation: the live nodes still carry the stamp the + // page put on them before any component code ran. + const stamped = (selector: string) => + page.evaluate( + (s) => document.querySelector(s)?.getAttribute("data-server-rendered"), + selector, + ); + expect(await stamped("#count")).toBe("1"); + expect(await stamped("#parity")).toBe("1"); + expect(await stamped("#items li")).toBe("1"); + + // Listeners are live, and they reach the guest through ordinary + // new-event-listener ops rather than the marker's `,click:1` suffix, + // which the host ignores (host/src/applier.ts's hydrate). + await page.locator("#inc").click(); + await expect(page.locator("#count")).toHaveText("1"); + await expect(page.locator("#parity")).toHaveText("count is 1"); + await expect(page.locator("#parity")).toHaveClass("odd"); + + // ...and the re-render mutated the adopted node rather than replacing it. + // A misbound id would have updated some other node, leaving this stamp + // intact but the text wrong — or replaced the node, dropping the stamp. + expect(await stamped("#count")).toBe("1"); + expect(await stamped("#parity")).toBe("1"); + + // The empty dynamic text (#echo renders as ``, with + // no text node for the host to adopt, so it creates one) accepts input. + await page.locator("#draft").fill("hello"); + await expect(page.locator("#echo")).toHaveText("hello"); + + // Structural mutation around adopted children. + await page.locator("#add").click(); + await expect(page.locator("#items li")).toHaveText(["alpha", "beta", "item-0"]); + expect(await stamped("#items li")).toBe("1"); + await page.locator("#remove").click(); + await expect(page.locator("#items li")).toHaveText(["alpha", "beta"]); + + // prevent_default through a hydrated form: a real submit would navigate. + await page.locator("#submit").click(); + await expect(page.locator("#submitted")).toHaveText("submitted 1 time(s)"); + expect(page.url()).toBe(hydrateUrl()); + + expect(await page.evaluate(() => (globalThis as unknown as { __e2eErrors: unknown[] }).__e2eErrors)).toEqual([]); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/examples/counter/golden.html b/examples/counter/golden.html index da803ab..c55629a 100644 --- a/examples/counter/golden.html +++ b/examples/counter/golden.html @@ -1 +1 @@ -
0

count is 0

  • alpha
  • beta

submitted 0 time(s)

\ No newline at end of file +
0

count is 0

  • alpha
  • beta

submitted 0 time(s)

\ No newline at end of file diff --git a/fixtures/surface-probe/src/lib.rs b/fixtures/surface-probe/src/lib.rs index fdd7071..dd0e7d7 100644 --- a/fixtures/surface-probe/src/lib.rs +++ b/fixtures/surface-probe/src/lib.rs @@ -273,7 +273,10 @@ thread_local! { struct Component; impl Guest for Component { - async fn run() -> wit_bindgen::rt::async_support::StreamReader { + // The mode is ignored: this probe builds a fixed op sequence with no + // Dioxus behind it, so it has no `pre-render`ed markup to adopt and + // nothing that could differ between `fresh` and `hydrate`. + async fn run(_mode: RenderMode) -> wit_bindgen::rt::async_support::StreamReader { // Create the channel and hand the read end back as `run`'s return // value (wit: `export run: async func() -> stream`). // Nothing is written from this body: a write here would park waiting diff --git a/harness/entry.ts b/harness/entry.ts index b567200..9cb2a37 100644 --- a/harness/entry.ts +++ b/harness/entry.ts @@ -36,6 +36,9 @@ import { mountApp } from "../host/src/host.ts"; declare global { interface Window { __DEFAULT_APP?: string; + /** Set by the page e2e/server.ts synthesizes at `/hydrate.html`, whose + * `#app` already holds the app's prerendered markup. */ + __HYDRATE?: boolean; } } @@ -117,9 +120,12 @@ async function main(): Promise { // sha-256, so a mismatched deploy fails loudly at instantiation. const source = artifactsFromEnvelope(envelopeText, componentBytes); + const hydrate = (globalThis as unknown as Window).__HYDRATE === true; + const mounted = await mountApp({ source, root, + hydrate, onError: (err) => { errors.push({ source: "onError", detail: err instanceof Error ? (err.stack ?? err.message) : String(err) }); }, @@ -137,8 +143,17 @@ async function main(): Promise { // - window.__e2eErrors: collected page/onError errors (asserted empty). (globalThis as unknown as { __mountedHandle: typeof mounted }).__mountedHandle = mounted; + // Hydrating, the app's markup is present from the first byte, so the + // usual "has the initial render landed" selector is already satisfied + // before the component even runs. What is observable instead is the + // hydrate operation consuming the server's text markers — the same signal + // host/tests/hydrate_component_test.ts waits on. const mountedSelector = APP_MOUNTED_SELECTOR[app] ?? "#count"; - await waitFor(() => root.querySelector(mountedSelector) !== null); + await waitFor( + hydrate + ? () => !root.innerHTML.includes("text / - + // 0x80 is NodeFilter.SHOW_COMMENT's numeric value. `NodeFilter` itself + // is a DOM global linkedom does not define (createTreeWalker works + // against comments there, only the NodeFilter object is missing) — the + // mask is spec-stable (DOM Standard §NodeFilter), so passing it + // literally works identically under linkedom and a real browser. + const SHOW_COMMENT = 0x80; + const walker = this.#doc.createTreeWalker(root, SHOW_COMMENT); + const comments: Comment[] = []; + let cur = walker.nextNode(); + while (cur) { + comments.push(cur as unknown as Comment); + cur = walker.nextNode(); + } + + for (const comment of comments) { + const text = comment.textContent ?? ""; + // Anchored, unlike upstream's `text.split("placeholder")` / + // `text.split("node-id")`: those match the marker word ANYWHERE in a + // comment, so an unrelated comment in the served markup would be read + // as a marker. Since we then validate indices, that misread would + // surface as a spurious duplicate/out-of-range error rather than + // upstream's silent misbinding — a strictly worse failure, so match + // the exact forms `pre_render` writes instead + // (dioxus-ssr-0.7.9 src/renderer.rs:189,215). + const placeholder = PLACEHOLDER_MARKER.exec(text); + if (placeholder) { + const n = parseInt(placeholder[1], 10); + checkIndex(n, `placeholder marker "${text}"`); + this.#setNode(ids[n], comment); + continue; + } + const textMarker = TEXT_MARKER.exec(text); + if (textMarker) { + const n = parseInt(textMarker[1], 10); + checkIndex(n, `text marker "${text}"`); + // ref:core.ts:281-291 — an empty dynamic text serializes as two + // adjacent comments with no text node between them; create one for + // the id to bind to. Otherwise the next sibling is the real text. + const next = comment.nextSibling; + let textNode: Node; + if (next !== null && next.nodeType === COMMENT_NODE) { + textNode = this.#doc.createTextNode(""); + comment.parentNode!.insertBefore(textNode, next); + } else { + textNode = next as Node; + } + this.#setNode(ids[n], textNode); + // Consume the closing `` too (ref:core.ts's + // `commentAfterText.remove()`); it carries no index of its own. + // Checked rather than assumed: `pre_render` always closes a dynamic + // text, so its absence means the markup is not what this component + // rendered, and removing whatever happened to follow would corrupt + // the document on the way to a later error. + const closing = textNode.nextSibling; + if (closing === null || closing.nodeType !== COMMENT_NODE || closing.textContent !== "#") { + throw new Error( + `DomApplier.hydrate: text marker "${text}" is not closed by `, + ); + } + closing.parentNode?.removeChild(closing); + comment.parentNode?.removeChild(comment); + } + } + + for (let n = 0; n < ids.length; n++) { + if (matched[n] === 0) { + throw new Error(`DomApplier.hydrate: marker index ${n} was never matched by any marker`); + } + } + } } diff --git a/host/src/host.ts b/host/src/host.ts index 54ef348..6083359 100644 --- a/host/src/host.ts +++ b/host/src/host.ts @@ -28,10 +28,20 @@ export interface MountOptions { * verbatim to `instantiate`. */ source: InstantiateSource; root: Element; + /** Request `render-mode.hydrate` instead of the default `render-mode. + * fresh` (wit/world.wit world `app`'s `render-mode` variant). Setting + * this is an assertion by the caller that `root` already holds this + * exact component's markup, prerendered at its initial state by + * `dioxus-ssr`'s `pre_render` — hydration is positional, not compared + * against the vdom, so a mismatch (wrong component, wrong initial + * state, edited markup) is a build-skew bug, and the host reports it as + * a thrown Error (`DomApplier.hydrate`) rather than silently repairing + * or falling back to a fresh render. Defaults to `false` (fresh). */ + hydrate?: boolean; /** Asynchronous failure after a successful mount: the mutation stream's * read loop rejecting (guest trap — `PeerTrappedError` — or teardown), or * a `handle-event` call rejecting. A failure during mount itself is NOT - * routed here: `await exports.run()` rejects and `mountApp` throws it to + * routed here: `await exports.run(mode)` rejects and `mountApp` throws it to * the caller. */ onError?: (err: unknown) => void; } @@ -384,12 +394,12 @@ export async function mountApp(opts: MountOptions): Promise { ...wasi(), // Keyed by the verbatim interface id (contract:"Module wiring and // instantiation"), so the version tracks the WIT package version — - // now 0.5.0. `events`' sole host-implemented item is the `dom-event` + // now 0.6.0. `events`' sole host-implemented item is the `dom-event` // resource, named by its bindgen-emitted UpperCamel name // (contract:"Resources"); `dom`'s items are functions, named by their // bindgen-emitted lowerCamel names. - "polymorph:dioxus/events@0.5.0": { DomEvent }, - "polymorph:dioxus/dom@0.5.0": createDomImports(applier, gate), + "polymorph:dioxus/events@0.6.0": { DomEvent }, + "polymorph:dioxus/dom@0.6.0": createDomImports(applier, gate), }; const instance = await instantiate(opts.source, imports); @@ -400,7 +410,13 @@ export async function mountApp(opts: MountOptions): Promise { // promise settles as soon as the guest hands the reader back (the app's // scheduler keeps running as a spawned guest task). A trap before the // return rejects here and propagates out of `mountApp`. - const ops = await (instance.exports.run as () => Promise>)(); + // + // `render-mode` is a payload-less variant, so it lowers as `{ kind: + // "fresh" }` / `{ kind: "hydrate" }` — same shape as the existing + // `{ kind: "none" }` / `{ kind: "dynamic" }` arms in operations.ts + // (contract:"Value mapping"). + const mode = { kind: opts.hydrate ? "hydrate" : "fresh" }; + const ops = await (instance.exports.run as (m: unknown) => Promise>)(mode); // The guest scheduler's persistent park between renders needs SOME // host-side reason the store's deadlock verdict stays suppressed. diff --git a/host/src/operations.ts b/host/src/operations.ts index 181aeae..bec60ec 100644 --- a/host/src/operations.ts +++ b/host/src/operations.ts @@ -108,7 +108,8 @@ export type Operation = | { kind: "new-event-listener"; value: EventListenerLifted } | { kind: "remove-event-listener"; value: EventListenerLifted } | { kind: "remove"; value: number } - | { kind: "push-root"; value: number }; + | { kind: "push-root"; value: number } + | { kind: "hydrate"; value: number[] }; /** * Rehydrate a `register-template` arena (`nodes` flat pre-order list, @@ -254,6 +255,9 @@ export function applyOperations(ops: Operation[], sink: OpSink): void { case "push-root": sink.pushRoot(op.value); break; + case "hydrate": + sink.hydrate(op.value); + break; default: { const _exhaustive: never = op; throw new Error(`applyOperations: unknown operation "${(_exhaustive as { kind: string }).kind}"`); diff --git a/host/tests/hydrate_component_test.ts b/host/tests/hydrate_component_test.ts new file mode 100644 index 0000000..443d92d --- /dev/null +++ b/host/tests/hydrate_component_test.ts @@ -0,0 +1,233 @@ +// End-to-end hydration: the REAL Dioxus counter example +// (examples/counter/src/lib.rs) adopting its own server-rendered markup. +// +// This is the only place the two halves of hydration meet. src/hydrate.rs's +// walk and host/src/applier.ts's DOM walk are each tested in isolation +// (tests/hydration_order.rs proves the walk emits one id per marker; +// host/tests/hydrate_test.ts proves the applier binds hand-written markers), +// but *correspondence* — that id n really is the node the server numbered n — +// has no meaning until a real component's ids meet a real component's HTML. +// Node identity is the proof: if hydration were quietly re-rendering, every +// assertion below about "the same object" would fail while the visible DOM +// looked perfect. +// +// Requires `just example counter` (the component) and `just ssg-example +// counter` (the prerendered HTML, which the SSG artifact and this test share +// as one golden file). +// +// Assertions are pinned to examples/counter/src/lib.rs — the authority for +// element ids, structure and text — exactly as counter_test.ts is. + +import { assertEquals, assertNotStrictEquals, assertStrictEquals } from "jsr:@std/assert@1"; +import { parseHTML } from "linkedom"; +import { defaultTranslator } from "@deltic/translator"; +import { mountApp } from "../src/host.ts"; +import type { NativeEventLike } from "../src/events.ts"; + +const COMPONENT_PATH = "../../examples/build/counter.component.wasm"; +const PRERENDERED_PATH = "../../examples/counter/golden.html"; + +async function waitFor(cond: () => boolean, what: string, maxIters = 2000): Promise { + for (let i = 0; i < maxIters; i++) { + if (cond()) return; + await new Promise((r) => setTimeout(r, 0)); + } + throw new Error(`waitFor timed out: ${what}`); +} + +async function readFixture(path: string, recipe: string): Promise { + const url = new URL(path, import.meta.url); + try { + return await Deno.readFile(url); + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + throw new Error(`fixture not found at ${url}. Run \`${recipe}\` first.`); + } + throw e; + } +} + +async function prerenderedHtml(): Promise { + const bytes = await readFixture(PRERENDERED_PATH, "just ssg-example counter"); + return new TextDecoder().decode(bytes); +} + +/** A mount root already holding the server's markup, as a browser would. */ +function serverRenderedRoot(html: string) { + const { document } = parseHTML( + `
${html}
`, + ); + return document.getElementById("root")!; +} + +function byId(root: Element, id: string): Element { + const el = root.querySelector(`#${id}`); + if (!el) throw new Error(`no element with id=${id} in ${root.innerHTML}`); + return el; +} + +function click(): NativeEventLike & { readonly prevented: number } { + let prevented = 0; + return { + type: "click", + clientX: 0, + clientY: 0, + button: 0, + buttons: 0, + preventDefault: () => prevented++, + stopPropagation: () => {}, + get prevented() { + return prevented; + }, + }; +} + +Deno.test("counter example hydrates its own prerendered markup: nodes are adopted, not rebuilt", async () => { + const html = await prerenderedHtml(); + const root = serverRenderedRoot(html); + const componentBytes = await readFixture(COMPONENT_PATH, "just example counter"); + const translator = await defaultTranslator(); + + // Identities captured BEFORE the component ever runs. Everything this test + // proves rests on these still being the live nodes afterwards. + const before = { + app: root.firstElementChild!, + count: byId(root, "count"), + parity: byId(root, "parity"), + inc: byId(root, "inc"), + draft: byId(root, "draft"), + items: Array.from(root.querySelectorAll("#items li")), + }; + assertEquals(before.count.textContent, "0", "server rendered the initial state"); + assertEquals(before.items.length, 2); + + const errors: unknown[] = []; + const mounted = await mountApp({ + source: { componentBytes, translator }, + root, + hydrate: true, + onError: (err) => errors.push(err), + }); + + // 1) Hydration landed. There is no "mount finished" signal to wait on — + // in hydrate mode the first batch creates nothing — so wait on the one + // observable side effect of applying it: the marker comments are consumed. + await waitFor(() => !root.innerHTML.includes("` in #echo — the case + // with no text node to adopt, so the host creates one) accepts text. + mounted.dispatch(before.draft, "input", { type: "input", value: "hello" }); + await waitFor(() => byId(root, "echo").textContent === "hello", "echo updates on input"); + + // 6) Structural mutation against a hydrated subtree: the keyed list grows + // and shrinks around the adopted
  • s. + const items = () => Array.from(root.querySelectorAll("#items li")).map((li) => li.textContent); + const add = byId(root, "add"); + mounted.dispatch(add, "click", click()); + await waitFor(() => items().length === 3, "list grows"); + assertEquals(items(), ["alpha", "beta", "item-0"]); + assertStrictEquals(root.querySelectorAll("#items li")[0], before.items[0], "existing rows untouched"); + + const remove = byId(root, "remove"); + mounted.dispatch(remove, "click", click()); + await waitFor(() => items().length === 2, "list shrinks"); + assertEquals(items(), ["alpha", "beta"]); + + // 7) prevent_default still reaches the guest through the hydrated form. + const form = byId(root, "form"); + const submit = click(); + mounted.dispatch(form, "submit", { ...submit, type: "submit" }); + await waitFor( + () => byId(root, "submitted").textContent === "submitted 1 time(s)", + "submit handled", + ); + assertEquals(submit.prevented, 1, "prevent-default called by onsubmit handler"); + + assertEquals(errors, [], "no onError callback ever fired"); + mounted.dispose(); +}); + +Deno.test("hydration mismatch is reported, not repaired", async () => { + // Build skew, minimally expressed: one element marker removed from + // otherwise-correct markup. The guest still emits an id for it, so the + // host finds an index nothing matched. wit/world.wit's `hydrate` doc makes + // this an error rather than a fallback — a silent fresh re-render would + // hide the skew and double the document. + const html = (await prerenderedHtml()).replace(' data-node-hydration="1,click:1"', ""); + const root = serverRenderedRoot(html); + const componentBytes = await readFixture(COMPONENT_PATH, "just example counter"); + const translator = await defaultTranslator(); + + const errors: unknown[] = []; + const mounted = await mountApp({ + source: { componentBytes, translator }, + root, + hydrate: true, + onError: (err) => errors.push(err), + }); + + await waitFor(() => errors.length > 0, "mismatch surfaced through onError"); + assertEquals(errors.length, 1); + const message = String(errors[0]); + assertStrictEquals( + message.includes("never matched"), + true, + `expected an unmatched-index error, got: ${message}`, + ); + + mounted.dispose(); +}); + +Deno.test("fresh mode is unaffected: an empty root still builds new nodes", async () => { + // The control for the identity assertions above. Same component, same + // assertions in spirit, but mounted the ordinary way into an empty root: + // the nodes it ends up with are necessarily new ones, so "same object" + // above is a property of hydration and not of the test's plumbing. + const html = await prerenderedHtml(); + const detached = serverRenderedRoot(html); + const serverCount = byId(detached, "count"); + + const root = serverRenderedRoot(""); + const componentBytes = await readFixture(COMPONENT_PATH, "just example counter"); + const translator = await defaultTranslator(); + + const errors: unknown[] = []; + const mounted = await mountApp({ + source: { componentBytes, translator }, + root, + onError: (err) => errors.push(err), + }); + + await waitFor(() => root.querySelector("#count") !== null, "fresh mount built the tree"); + assertNotStrictEquals(byId(root, "count"), serverCount); + assertEquals(byId(root, "count").textContent, "0"); + assertEquals(errors, []); + + mounted.dispose(); +}); diff --git a/host/tests/hydrate_test.ts b/host/tests/hydrate_test.ts new file mode 100644 index 0000000..9d1f072 --- /dev/null +++ b/host/tests/hydrate_test.ts @@ -0,0 +1,193 @@ +// Unit tests for `DomApplier.hydrate` / the `hydrate` operation, driven via +// `applyOperations` against hand-written prerendered fragments in linkedom. +// No component is instantiated: the prebuilt `.wasm` fixtures under +// examples/build and fixtures/build are stale against the new WIT until a +// rebuild, so any test loading one would fail for reasons unrelated to +// hydration (dispatch note in this track's dispatch). +// +// Marker HTML is lifted from dioxus-ssr-0.7.9's own hydration.rs tests +// (cited per fragment below), not invented, per this track's dispatch. + +import { assertEquals, assertStrictEquals, assertThrows } from "jsr:@std/assert@1"; +import { parseHTML } from "linkedom"; +import { DomApplier, type ListenerDelegate } from "../src/applier.ts"; +import { applyOperations } from "../src/operations.ts"; +import type { Operation } from "../src/operations.ts"; + +function makeRoot(innerHTML: string) { + const { document } = parseHTML(`
    ${innerHTML}
    `); + const root = document.getElementById("root")!; + return { document, root }; +} + +function recordingDelegate(): { events: unknown[]; delegate: ListenerDelegate } { + const events: unknown[] = []; + return { + events, + delegate: { + add(_el, elementId, nameId, name, bubbles) { + events.push({ op: "add", elementId, nameId, name, bubbles }); + }, + remove(_el, elementId, nameId, name, bubbles) { + events.push({ op: "remove", elementId, nameId, name, bubbles }); + }, + purge(elementId, el) { + events.push({ op: "purge", elementId, el }); + }, + }, + }; +} + +// dioxus-ssr-0.7.9 tests/hydration.rs `dynamic_attributes`: +//
    +Deno.test("hydrate: element marker on the mount root itself, and a nested one, bind the SAME nodes already in the document", () => { + const { root } = makeRoot( + '
    ', + ); + root.setAttribute("data-node-hydration", "0"); + const inner = root.firstElementChild!; + const { delegate } = recordingDelegate(); + const applier = new DomApplier(root, delegate); + + applyOperations([{ kind: "hydrate", value: [100, 200] } as unknown as Operation], applier); + + // Identity, not just equality — the property that distinguishes + // hydration from re-rendering (dispatch). + assertStrictEquals(applier.nodeFor(100), root); + assertStrictEquals(applier.nodeFor(200), inner); +}); + +// dioxus-ssr-0.7.9 tests/hydration.rs `listeners`: +//
    +Deno.test("hydrate: a marker with a listener suffix binds and the suffix is IGNORED — no listener attached, no data-dioxus-id set", () => { + const { root } = makeRoot('
    '); + root.setAttribute("data-node-hydration", "0"); + const inner = root.firstElementChild!; + const { events, delegate } = recordingDelegate(); + const applier = new DomApplier(root, delegate); + + applyOperations([{ kind: "hydrate", value: [10, 20] } as unknown as Operation], applier); + + assertStrictEquals(applier.nodeFor(20), inner); + assertEquals(events, [], "hydrate itself must not call the ListenerDelegate"); + assertEquals(inner.hasAttribute("data-dioxus-id"), false, "EventDispatcher.add sets this, not hydrate"); +}); + +// dioxus-ssr-0.7.9 tests/hydration.rs `text_nodes`: +//
    hello
    +Deno.test("hydrate: dynamic text — the text node between the markers binds, and BOTH marker comments are gone", () => { + const { root } = makeRoot("hello"); + root.setAttribute("data-node-hydration", "0"); + const { delegate } = recordingDelegate(); + const applier = new DomApplier(root, delegate); + + applyOperations([{ kind: "hydrate", value: [1, 2] } as unknown as Operation], applier); + + const textNode = applier.nodeFor(2); + assertEquals(textNode?.nodeType, 3 /* TEXT_NODE */); + assertEquals(textNode?.textContent, "hello"); + assertEquals(root.innerHTML, "hello", "both and must be consumed"); +}); + +// dioxus-ssr-0.7.9 tests/hydration.rs `components_hydrate`'s `Child4` +// (empty-dynamic-text case): 11 +// adapted here to the empty-text shape core.ts:281-291 documents: +// with no text node between the comments. +Deno.test("hydrate: empty dynamic text — a text node is CREATED, bound, and left in the document", () => { + const { root } = makeRoot(""); + const { delegate } = recordingDelegate(); + const applier = new DomApplier(root, delegate); + + applyOperations([{ kind: "hydrate", value: [5] } as unknown as Operation], applier); + + const textNode = applier.nodeFor(5); + assertEquals(textNode?.nodeType, 3 /* TEXT_NODE */); + assertEquals(textNode?.textContent, ""); + assertStrictEquals(textNode?.parentNode, root); + assertEquals(root.innerHTML, "", "the created empty text node renders as nothing"); +}); + +// Placeholder marker format from dioxus-ssr-0.7.9 src/renderer.rs:215 +// (`write!(buf, "", ...)`) — no `pre_render` output in +// hydration.rs happens to contain one, so the shape (not a full app) is +// taken straight from the renderer source (dispatch cites this authority). +Deno.test("hydrate: a placeholder comment binds to the comment node itself", () => { + const { root } = makeRoot(""); + const { delegate } = recordingDelegate(); + const applier = new DomApplier(root, delegate); + const placeholderComment = root.firstChild!; + + applyOperations([{ kind: "hydrate", value: [42] } as unknown as Operation], applier); + + assertStrictEquals(applier.nodeFor(42), placeholderComment); + assertEquals(placeholderComment.nodeType, 8 /* COMMENT_NODE */); +}); + +// dioxus-ssr-0.7.9 tests/hydration.rs `hello_world_hydrates`. +Deno.test("hydrate: a subsequent set-text/set-attribute op against a hydrated id reaches the pre-existing node", () => { + const { root } = makeRoot( + '

    High-Five counter: 0

    ' + + '', + ); + const h1 = root.firstElementChild!; + const button = root.lastElementChild!; + const { delegate } = recordingDelegate(); + const applier = new DomApplier(root, delegate); + + applyOperations( + [ + { kind: "hydrate", value: [10, 11, 12] } as unknown as Operation, + { kind: "cache-string", value: { id: 0, str: "class" } } as unknown as Operation, + { kind: "set-text", value: { id: 11, text: "High-Five counter: 1" } } as unknown as Operation, + { + kind: "set-attribute", + value: { id: 12, name: 0, value: { kind: "text", value: "active" } }, + } as unknown as Operation, + ], + applier, + ); + + assertStrictEquals(applier.nodeFor(10), h1); + assertStrictEquals(applier.nodeFor(12), button); + assertEquals(h1.textContent, "High-Five counter: 1"); + assertEquals(button.getAttribute("class"), "active"); +}); + +// -- validation ------------------------------------------------------------- + +Deno.test("hydrate: throws on an out-of-range marker index", () => { + const { root } = makeRoot('
    '); + const { delegate } = recordingDelegate(); + const applier = new DomApplier(root, delegate); + + assertThrows( + () => applyOperations([{ kind: "hydrate", value: [1, 2] } as unknown as Operation], applier), + Error, + ); +}); + +Deno.test("hydrate: throws on a duplicated marker index", () => { + const { root } = makeRoot( + '
    ', + ); + const { delegate } = recordingDelegate(); + const applier = new DomApplier(root, delegate); + + assertThrows( + () => applyOperations([{ kind: "hydrate", value: [1] } as unknown as Operation], applier), + Error, + ); +}); + +Deno.test("hydrate: throws when a marker index is never matched", () => { + const { root } = makeRoot('
    '); + const { delegate } = recordingDelegate(); + const applier = new DomApplier(root, delegate); + + // ids has two slots (0 and 1), but only marker "0" is present in the DOM. + assertThrows( + () => applyOperations([{ kind: "hydrate", value: [1, 2] } as unknown as Operation], applier), + Error, + ); +}); diff --git a/host/tests/operations_test.ts b/host/tests/operations_test.ts index 354861d..5fc87db 100644 --- a/host/tests/operations_test.ts +++ b/host/tests/operations_test.ts @@ -243,6 +243,9 @@ function recordingSink(ops: unknown[]): OpSink { pushRoot(id) { ops.push({ op: "push-root", id }); }, + hydrate(ids) { + ops.push({ op: "hydrate", ids }); + }, }; } diff --git a/justfile b/justfile index c788487..3d2dc98 100644 --- a/justfile +++ b/justfile @@ -129,9 +129,10 @@ ssr-example name: serve name: #!/usr/bin/env bash set -euo pipefail - if [ ! -f examples/build/{{name}}.ssr.component.wasm ]; then - just ssr-example {{name}} - fi + # Unconditional rather than guarded on the file existing: nothing is + # worse than demoing a stale build, and cargo makes the up-to-date case + # free. + just ssr-example {{name}} wasmtime serve -S cli examples/build/{{name}}.ssr.component.wasm # Smoke-test the served component: it answers, with the golden HTML. @@ -143,9 +144,11 @@ serve name: serve-test name: #!/usr/bin/env bash set -euo pipefail - if [ ! -f examples/build/{{name}}.ssr.component.wasm ]; then - just ssr-example {{name}} - fi + # Unconditional rather than guarded on the file existing: a stale + # artifact from before a source change fails as a confusing golden diff + # rather than as "you forgot to rebuild", and cargo makes the up-to-date + # case free. + just ssr-example {{name}} log=$(mktemp) wasmtime serve -S cli --addr 127.0.0.1:0 \ examples/build/{{name}}.ssr.component.wasm > "$log" 2>&1 & @@ -194,6 +197,11 @@ e2e: if [ ! -f examples/build/todomvc.component.wasm ]; then just example todomvc fi + # Unconditional, unlike the component builds above: tests/hydrate.spec.ts + # serves examples/counter/golden.html as the page the client adopts, so a + # golden that has drifted from the component would fail as a hydration + # mismatch. This recipe re-derives and diffs it rather than trusting it. + just ssg-example counter deno run -A harness/build.ts cd e2e && npx playwright test diff --git a/src/driver.rs b/src/driver.rs index 88e0bb5..78e31ac 100644 --- a/src/driver.rs +++ b/src/driver.rs @@ -82,8 +82,9 @@ use wit_bindgen::rt::async_support::{spawn_local, StreamReader, StreamWriter}; use crate::bindings::polymorph::dioxus::events::Payload; use crate::bindings::polymorph::dioxus::mutations::Operation; -use crate::bindings::{wit_stream, DomEvent}; +use crate::bindings::{wit_stream, DomEvent, RenderMode}; use crate::events::{WitEventConverter, WitEventData}; +use crate::hydrate::hydration_ids; use crate::interner::Interner; use crate::writer::MutationWriter; @@ -251,7 +252,7 @@ fn render(step: impl FnOnce(&mut VirtualDom, &mut MutationWriter)) { /// scheduler forever), and returns the stream's read end to the host. See /// the module doc for why the scheduler must be a spawned task and why /// nothing is written before the return. -pub async fn run(root: fn() -> Element) -> MutationStream { +pub async fn run(root: fn() -> Element, mode: RenderMode) -> MutationStream { // dioxus-html's converter slot is global and write-once per process; a // component instance is a fresh process image, so this runs exactly once. dioxus_html::set_event_converter(Box::new(WitEventConverter)); @@ -274,7 +275,41 @@ pub async fn run(root: fn() -> Element) -> MutationStream { })); spawn_local(async move { - render(|dom, w| dom.rebuild(w)); + match mode { + RenderMode::Fresh => render(|dom, w| dom.rebuild(w)), + RenderMode::Hydrate => render(|dom, w| { + // The rebuild still runs — it is what assigns the ElementIds + // the `hydrate` payload binds — but emits no node-creating + // operations; the listener registrations it does emit are + // deliberate (see `MutationWriter::suppress_nodes`). + w.suppress_nodes(true); + dom.rebuild(w); + let ids = hydration_ids(dom).unwrap_or_else(|e| { + // Unreachable after a completed rebuild: every node the + // walk visits has a mount entry by then. Reaching it + // means the walk and dioxus-core disagree about the tree + // — a guest bug with no recoverable branch, and + // continuing would emit a shorter id list that the host + // would bind positionally to the wrong nodes. Panicking + // traps the instance, which surfaces to the host as a + // rejected read on the mutation stream (see "How failure + // surfaces" above); `expect`-style loudness matches the + // uninitialized-thread-local handling elsewhere here, + // while the merely-unexpected `handle-event` cases stay + // on the debug_assert-and-drop path. + panic!("driver: hydration walk failed: {e}") + }); + // Contractual: `hydrate` must be the first operation of the + // first batch, ahead of the `new-event-listener` ops that + // reference the ids it binds (`wit/world.wit`, the `hydrate` + // type doc). The rebuild has already filled the batch with + // those listener ops, so it goes in at index 0. + w.batch.insert(0, Operation::Hydrate(ids)); + // Initial render only: every later render is byte-identical + // to `fresh` mode. + w.suppress_nodes(false); + }), + } flush().await; // The scheduler loop's persistent park is legal because the host @@ -344,8 +379,8 @@ macro_rules! launch { struct __PolyengineDioxusApp; impl $crate::bindings::Guest for __PolyengineDioxusApp { - async fn run() -> $crate::driver::MutationStream { - $crate::driver::run($root).await + async fn run(mode: $crate::bindings::RenderMode) -> $crate::driver::MutationStream { + $crate::driver::run($root, mode).await } async fn handle_event( diff --git a/src/hydrate.rs b/src/hydrate.rs new file mode 100644 index 0000000..f556ddf --- /dev/null +++ b/src/hydrate.rs @@ -0,0 +1,173 @@ +//! The hydration id walk: the payload of the `hydrate` operation. +//! +//! In `render-mode.hydrate` the mount root already holds this component's +//! markup, written by `dioxus-ssr`'s `pre_render`. The guest still runs the +//! initial render — that is what makes dioxus-core assign ElementIds and +//! build its mount table — but emits no node-creating operations. What the +//! host needs instead is the binding: which already-present DOM node is +//! which ElementId. [`hydration_ids`] produces exactly that, as +//! `ids[n] = the ElementId for the server's hydration marker n`. +//! +//! # Why the correspondence is positional, not structural +//! +//! Neither side matches HTML against the vdom. `pre_render` writes a marker +//! wherever its own template walk reaches a node the client will need to +//! address, numbering them with a single monotonic counter +//! (`dynamic_node_id`, bumped at `Segment::AttributeNodeMarker`, +//! `Segment::RootNodeMarker`, `DynamicNode::Text` and +//! `DynamicNode::Placeholder` — dioxus-ssr-0.7.9 src/renderer.rs:189,215,267,281). +//! This module walks the *same* templates in the *same* order and pushes an +//! id at exactly those four sites. So marker `n` and `ids[n]` are the `n`th +//! stop of one walk described twice, and the host can bind them by counting +//! rather than by comparing trees. +//! +//! That makes the mode's precondition sharp: the served HTML must come from +//! this component at this initial state. A structural disagreement does not +//! degrade into a partial match — it shifts the numbering, and the host +//! reports the count/marker mismatch rather than binding wrong nodes (see +//! the `hydrate` doc in `wit/world.wit`). +//! +//! # Where the walk comes from +//! +//! Ported from dioxus-web-0.7.10 src/hydration/hydrate.rs:244-372 +//! (`rehydrate_scope` / `rehydrate_vnode` / `rehydrate_template_node` / +//! `rehydrate_dynamic_node`), minus its suspense bookkeeping (out of scope +//! here) and minus its `to_mount` vector: dioxus-web has to rediscover +//! `onmounted` elements during the walk because its hydration path also +//! suppresses `create_event_listener`. We do not — listener registrations +//! flow as ordinary `new-event-listener` operations, `mounted` included, so +//! there is nothing for a `to_mount` list to do. See the suppression comment +//! in [`crate::writer`]. +//! +//! This module depends only on `dioxus-core`, so it is not `wasm32`-gated +//! and `cargo test` exercises the walk natively against `dioxus_ssr` — which +//! is the only place the two orders can be checked against each other. + +use dioxus_core::{ + DynamicNode, ElementId, ScopeState, TemplateAttribute, TemplateNode, VNode, + VirtualDom, +}; + +/// The walk reached a node the VirtualDom has not mounted. +/// +/// `mounted_root` / `mounted_dynamic_node` / `mounted_dynamic_attribute` / +/// `mounted_scope` all return `Option`, being `None` for a vnode whose mount +/// entry does not exist yet. After the initial `rebuild` every node the walk +/// visits has one, so this is unreachable in the driver's usage — but it is +/// a real `Option` in the API and silently pushing a wrong id would corrupt +/// the positional correspondence, so it is an error, not an `unwrap`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VNodeNotInitialized; + +impl std::fmt::Display for VNodeNotInitialized { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("hydration walk reached a vnode with no mount entry") + } +} + +impl std::error::Error for VNodeNotInitialized {} + +/// Collect the ElementIds of `dom`'s rendered tree in `pre_render`'s marker +/// order. `dom` must already have been rebuilt. +pub fn hydration_ids(dom: &VirtualDom) -> Result, VNodeNotInitialized> { + let mut ids = Vec::new(); + scope(dom, dom.base_scope(), &mut ids)?; + Ok(ids) +} + +fn scope( + dom: &VirtualDom, + scope: &ScopeState, + ids: &mut Vec, +) -> Result<(), VNodeNotInitialized> { + vnode(dom, scope.root_node(), ids) +} + +fn vnode(dom: &VirtualDom, node: &VNode, ids: &mut Vec) -> Result<(), VNodeNotInitialized> { + for (i, root) in node.template.roots.iter().enumerate() { + // Only roots carry a mounted id into `template_node`: a nested + // static node is addressable through its root and the server writes + // no marker for it. + let root_id = node.mounted_root(i, dom).ok_or(VNodeNotInitialized)?; + template_node(dom, node, root, Some(root_id), ids)?; + } + Ok(()) +} + +fn template_node( + dom: &VirtualDom, + vn: &VNode, + node: &TemplateNode, + root_id: Option, + ids: &mut Vec, +) -> Result<(), VNodeNotInitialized> { + match node { + TemplateNode::Element { children, attrs, .. } => { + // The server writes `data-node-hydration` on an element that is + // either a template root or carries dynamic attributes, and + // exactly once either way (dioxus-ssr-0.7.9 src/cache.rs:261-273 + // — `has_dyn_attrs || is_root`, with the attribute marker + // winning). Hence one `Option` narrowed by both conditions + // rather than two pushes. + let mut mounted_id = root_id; + for attr in *attrs { + if let TemplateAttribute::Dynamic { id } = attr { + let attr_id = + vn.mounted_dynamic_attribute(*id, dom).ok_or(VNodeNotInitialized)?; + // Claimed even when the attribute list is empty: an + // empty spread still needs the element mounted so a + // later render can fill it (dioxus-web hydrate.rs:301-305). + mounted_id = Some(attr_id); + // Upstream harvests `onmounted` listeners here into + // `to_mount`; we do not — see the module doc. + } + } + if let Some(id) = mounted_id { + ids.push(id.0 as u32); + } + for child in *children { + template_node(dom, vn, child, None, ids)?; + } + } + TemplateNode::Dynamic { id } => dynamic_node(dom, vn, &vn.dynamic_nodes[*id], *id, ids)?, + // A root text node gets `` so the client can find it + // again after adjacent text nodes merge (dioxus-ssr src/cache.rs:299-306); + // a nested one gets nothing, and arrives here with `root_id` None. + TemplateNode::Text { .. } => { + if let Some(id) = root_id { + ids.push(id.0 as u32); + } + } + } + Ok(()) +} + +fn dynamic_node( + dom: &VirtualDom, + vn: &VNode, + node: &DynamicNode, + index: usize, + ids: &mut Vec, +) -> Result<(), VNodeNotInitialized> { + match node { + // `text` and ``: one + // marker each, and the same counter. + DynamicNode::Text(_) | DynamicNode::Placeholder(_) => { + let id = vn.mounted_dynamic_node(index, dom).ok_or(VNodeNotInitialized)?; + ids.push(id.0 as u32); + } + // The server renders a component inline at this point in the byte + // stream, so the walk must descend here rather than after the + // parent template. + DynamicNode::Component(comp) => { + let child = comp.mounted_scope(index, vn, dom).ok_or(VNodeNotInitialized)?; + scope(dom, child, ids)?; + } + DynamicNode::Fragment(nodes) => { + for child in nodes { + vnode(dom, child, ids)?; + } + } + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 86dd759..8619ca5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,16 +2,22 @@ //! //! - [`interner`]: the `&'static str` name table behind the protocol's //! `cache-string` / `str-ref` interning. +//! - [`hydrate`]: the ElementId walk backing the protocol's `hydrate` +//! operation, in `dioxus-ssr` `pre-render`'s marker order. //! - `bindings` / `driver` / `events` / `writer` (wasm32 only): the generated //! WIT bindings, the `run`/`handle-event` implementation, the //! `HtmlEventConverter` over the WIT payload types, and the //! `dioxus_core::WriteMutations` sink that fills a batch of //! `mutations::operation` values. These are gated on //! `target_arch = "wasm32"` because they name the generated bindings; -//! [`interner`] does not, so `cargo test` can exercise it natively. +//! [`interner`] and [`hydrate`] do not, so `cargo test` can exercise them +//! natively — and for [`hydrate`] that is the only place it can be +//! exercised, since checking its order means running `dioxus-ssr` beside +//! it. //! //! An application crate wires itself up with [`launch!`]. +pub mod hydrate; pub mod interner; #[cfg(target_arch = "wasm32")] diff --git a/src/writer.rs b/src/writer.rs index e031da9..e465651 100644 --- a/src/writer.rs +++ b/src/writer.rs @@ -48,6 +48,9 @@ pub struct MutationWriter { /// hands that capacity back here — so a steady-state batch reuses one /// allocation. See `driver::flush`. pub batch: Vec, + /// While set, node-creating operations are dropped instead of encoded; + /// see [`MutationWriter::suppress_nodes`]. + suppressed: bool, interner: Rc>, /// Guest-assigned template ids, keyed by the pointer identity of /// `template`'s `roots`/`node_paths`/`attr_paths` slices — mirroring @@ -65,7 +68,46 @@ pub struct MutationWriter { impl MutationWriter { /// Create a writer sharing `interner` with the event-dispatch path. pub fn new(interner: Rc>) -> Self { - MutationWriter { batch: Vec::new(), interner, templates: FxHashMap::default() } + MutationWriter { + batch: Vec::new(), + suppressed: false, + interner, + templates: FxHashMap::default(), + } + } + + /// Drop node-creating operations rather than encoding them, for the + /// initial render of `render-mode.hydrate`: the nodes already exist in + /// the document, and the host binds them by id through the `hydrate` + /// operation instead. The rebuild still runs through this writer, so + /// dioxus-core assigns ElementIds and fills its mount table exactly as + /// in `fresh` — that assignment is the entire point of the pass, and is + /// what dioxus-web's own note distinguishes from running with no + /// mutation writer at all (dioxus-web-0.7.10 src/dom.rs:45-47). + /// + /// The suppressed methods return before interning or template + /// registration, so a suppressed rebuild also emits no `cache-string` + /// and no `register-template` — except the name interning the listener + /// ops below do for themselves. + /// + /// DELIBERATE DIVERGENCE FROM dioxus-web, which suppresses + /// `create_event_listener` too and rebuilds listeners by parsing the + /// `,click:1` suffix of `data-node-hydration` + /// (dioxus-web-0.7.10 src/hydration/hydrate.rs, `write_comma_separated`). + /// Here listener ops flow normally and the host ignores that suffix, + /// because: our `new-event-listener` carries an *interned* name id, + /// which the marker cannot supply; the host's synthetic `mounted` event + /// and its observer-backed `resize`/`visible` families are driven by + /// listener registration and are not expressible in the marker format + /// at all; and `data-dioxus-id` tagging already happens host-side in + /// `EventDispatcher.add`. Consequently there is no `to_mount` vector + /// and no special `onmounted` path — see [`crate::hydrate`]. + /// + /// Suppression covers the initial rebuild only; the driver clears it + /// before the first flush, and every later render is byte-identical to + /// `fresh` mode. + pub fn suppress_nodes(&mut self, suppressed: bool) { + self.suppressed = suppressed; } /// Intern `s`, pushing `Operation::CacheString` on first sight of this @@ -206,19 +248,35 @@ impl MutationWriter { impl WriteMutations for MutationWriter { fn append_children(&mut self, id: ElementId, m: usize) { + if self.suppressed { + return; + } + self.batch.push(m::Operation::AppendChildren(m::StackOp { id: id.0 as u32, m: m as u32 })); } fn assign_node_id(&mut self, path: &'static [u8], id: ElementId) { + if self.suppressed { + return; + } + self.batch .push(m::Operation::AssignId(m::AssignId { path: path.to_vec(), id: id.0 as u32 })); } fn create_placeholder(&mut self, id: ElementId) { + if self.suppressed { + return; + } + self.batch.push(m::Operation::CreatePlaceholder(id.0 as u32)); } fn create_text_node(&mut self, value: &str, id: ElementId) { + if self.suppressed { + return; + } + self.batch.push(m::Operation::CreateTextNode(m::CreateTextNode { id: id.0 as u32, text: value.to_string(), @@ -226,6 +284,10 @@ impl WriteMutations for MutationWriter { } fn load_template(&mut self, template: Template, index: usize, id: ElementId) { + if self.suppressed { + return; + } + let tmpl = self.template_id(template); self.batch.push(m::Operation::LoadTemplate(m::LoadTemplate { id: id.0 as u32, @@ -235,19 +297,35 @@ impl WriteMutations for MutationWriter { } fn replace_node_with(&mut self, id: ElementId, m: usize) { + if self.suppressed { + return; + } + self.batch.push(m::Operation::ReplaceWith(m::StackOp { id: id.0 as u32, m: m as u32 })); } fn replace_placeholder_with_nodes(&mut self, path: &'static [u8], m: usize) { + if self.suppressed { + return; + } + self.batch .push(m::Operation::ReplacePlaceholder(m::PathOp { path: path.to_vec(), m: m as u32 })); } fn insert_nodes_after(&mut self, id: ElementId, m: usize) { + if self.suppressed { + return; + } + self.batch.push(m::Operation::InsertAfter(m::StackOp { id: id.0 as u32, m: m as u32 })); } fn insert_nodes_before(&mut self, id: ElementId, m: usize) { + if self.suppressed { + return; + } + self.batch.push(m::Operation::InsertBefore(m::StackOp { id: id.0 as u32, m: m as u32 })); } @@ -258,6 +336,9 @@ impl WriteMutations for MutationWriter { value: &AttributeValue, id: ElementId, ) { + if self.suppressed { + return; + } // Intern first: the CacheStrings must precede the SetAttribute that // names their ids. let name_id = self.intern(name); @@ -284,6 +365,10 @@ impl WriteMutations for MutationWriter { } fn set_node_text(&mut self, value: &str, id: ElementId) { + if self.suppressed { + return; + } + self.batch .push(m::Operation::SetText(m::SetText { id: id.0 as u32, text: value.to_string() })); } @@ -307,10 +392,18 @@ impl WriteMutations for MutationWriter { } fn remove_node(&mut self, id: ElementId) { + if self.suppressed { + return; + } + self.batch.push(m::Operation::Remove(id.0 as u32)); } fn push_root(&mut self, id: ElementId) { + if self.suppressed { + return; + } + self.batch.push(m::Operation::PushRoot(id.0 as u32)); } } diff --git a/ssr/src/lib.rs b/ssr/src/lib.rs index a195bb4..6141a00 100644 --- a/ssr/src/lib.rs +++ b/ssr/src/lib.rs @@ -53,6 +53,16 @@ impl fmt::Write for FmtBridge { /// flushed before this returns. Wrap it in a [`std::io::BufWriter`] when its /// writes are expensive — for a component-model `stream` each one costs a /// host context switch. +/// +/// The output always carries hydration markers (`Renderer::pre_render`): +/// `data-node-hydration` attributes, `text` around +/// dynamic text and ``. They are what makes the markup +/// adoptable by the client renderer — `polymorph:dioxus`'s `render-mode. +/// hydrate` binds element ids to nodes by counting these, and there is no +/// other way to find them — and they are inert in a page that never +/// hydrates. There is no marker-free mode because nothing here wants one: +/// the whole point of rendering this markup on a server is that a client +/// takes it over. pub fn render_to(root: fn() -> Element, out: W) -> io::Result<()> { let mut dom = VirtualDom::new(root); dom.rebuild_in_place(); @@ -62,7 +72,10 @@ pub fn render_to(root: fn() -> Element, out: W) -> io::Result<()> err: None, }; - match Renderer::new().render_to(&mut sink, &dom) { + let mut renderer = Renderer::new(); + renderer.pre_render = true; + + match renderer.render_to(&mut sink, &dom) { // `BufWriter`'s own `Drop` flush swallows errors, so flush here where // the result can still be reported. Ok(()) => sink.inner.flush(), diff --git a/ssr/tests/render.rs b/ssr/tests/render.rs index 8ee5b51..ebe6197 100644 --- a/ssr/tests/render.rs +++ b/ssr/tests/render.rs @@ -25,12 +25,20 @@ fn Page() -> Element { } } +// Markers included: `render_to` always pre-renders (see its doc). They are +// the client's only handle on which node is which, so they belong in the +// expectation rather than being filtered out of it — a change to the marker +// numbering is a change to the hydration contract and should fail here. const EXPECTED: &str = concat!( - r#"
    "#, + r#"
    "#, "

    hello

    ", - r#"

    count is 3

    "#, - "
    • item-0
    • item-1
    • item-2
    ", - r#""#, + r#"

    count is 3

    "#, + "
      ", + r#"
    • item-0
    • "#, + r#"
    • item-1
    • "#, + r#"
    • item-2
    • "#, + "
    ", + r#""#, "
    ", ); diff --git a/tests/hydration_order.rs b/tests/hydration_order.rs new file mode 100644 index 0000000..71a7f39 --- /dev/null +++ b/tests/hydration_order.rs @@ -0,0 +1,224 @@ +//! The hydration walk agrees with `dioxus-ssr` `pre_render`'s marker +//! numbering. +//! +//! This is the only place the two orders can meet: `pre_render` numbers its +//! markers with one monotonic counter as it writes the HTML, and +//! `polyengine_dioxus::hydrate` walks the same templates pushing an +//! ElementId at the same four sites. The host binds `ids[n]` to marker `n` +//! by counting, so a porting slip in the walk shows up as a count mismatch +//! here — and as silently wrong nodes in production if it does not. +//! +//! The corpus is dioxus-ssr-0.7.9 tests/hydration.rs, whose apps between +//! them cover template roots, elements with dynamic attributes, listeners, +//! dynamic text, placeholders, child components, fragments, and (app4) a +//! tree that is two dynamic texts with no element marker at all. +//! +//! Positional correctness *within* a matching count is a DOM property and +//! belongs to the host-side test; there is no DOM here and nothing is +//! asserted about which node an id names. + +use dioxus::prelude::*; +use dioxus_core::{ + AttributeValue, ElementId, Template, WriteMutations, +}; +use polyengine_dioxus::hydrate::hydration_ids; + +/// A `WriteMutations` that encodes nothing. +/// +/// The production suppressed path runs through `MutationWriter` with +/// `suppress_nodes(true)`, but that module names the generated WIT bindings +/// and so is `wasm32`-only; this sink stands in for it natively. What +/// matters for the walk is the property both share and that +/// `rebuild`-with-no-writer would not: dioxus-core still assigns ElementIds +/// and fills its mount table (dioxus-web-0.7.10 src/dom.rs:45-47). +struct Discard; + +impl WriteMutations for Discard { + fn append_children(&mut self, _: ElementId, _: usize) {} + fn assign_node_id(&mut self, _: &'static [u8], _: ElementId) {} + fn create_placeholder(&mut self, _: ElementId) {} + fn create_text_node(&mut self, _: &str, _: ElementId) {} + fn load_template(&mut self, _: Template, _: usize, _: ElementId) {} + fn replace_node_with(&mut self, _: ElementId, _: usize) {} + fn replace_placeholder_with_nodes(&mut self, _: &'static [u8], _: usize) {} + fn insert_nodes_after(&mut self, _: ElementId, _: usize) {} + fn insert_nodes_before(&mut self, _: ElementId, _: usize) {} + fn set_attribute( + &mut self, + _: &'static str, + _: Option<&'static str>, + _: &AttributeValue, + _: ElementId, + ) { + } + fn set_node_text(&mut self, _: &str, _: ElementId) {} + fn create_event_listener(&mut self, _: &'static str, _: ElementId) {} + fn remove_event_listener(&mut self, _: &'static str, _: ElementId) {} + fn remove_node(&mut self, _: ElementId) {} + fn push_root(&mut self, _: ElementId) {} +} + +/// The marker numbers `pre_render` wrote, in document order. +/// +/// Three forms, one counter: `data-node-hydration="N` on an element (the +/// `,click:1` listener suffix is not our business — see the module doc of +/// `polyengine_dioxus::writer`), `` opening a dynamic or +/// root text node, and ``. +fn markers(html: &str) -> Vec { + let mut found: Vec<(usize, usize)> = Vec::new(); + for prefix in [r#"data-node-hydration=""#, "` arm of the walk is unexercised +/// (verified: deleting that arm leaves every other case green). +#[test] +fn empty_body_is_a_placeholder() { + fn app() -> Element { + rsx! { + div { + for _ in 0..0 { + div {} + } + } + } + } + check("empty_body_is_a_placeholder", app); +} diff --git a/wit/world.wit b/wit/world.wit index 04297c7..6be4f36 100644 --- a/wit/world.wit +++ b/wit/world.wit @@ -12,7 +12,7 @@ /// WIT package: any change to the operation vocabulary or its record shapes /// bumps the package version (instantiation is digest-checked, so a mismatch /// fails loudly and early). -package polymorph:dioxus@0.5.0; +package polymorph:dioxus@0.6.0; /// Typed event payloads, host-serialized at dispatch. One snapshot per /// event; fields not applicable to an event family are simply absent by @@ -412,6 +412,36 @@ interface mutations { record event-listener { id: element-id, name: str-ref, bubbles: bool } + /// Adopt server-rendered HTML: bind already-present DOM nodes to the + /// element ids the guest just assigned, instead of creating nodes. + /// + /// The payload is the guest's traversal of its own template tree, in the + /// order `dioxus-ssr`'s `pre-render` numbered the markers it wrote into + /// the HTML: `ids[n]` is the element id for hydration marker `n`. Neither + /// side compares HTML against the vdom — the correspondence is positional, + /// and both orders come from the same template walk. + /// + /// The host finds marker `n` by walking the mount root: elements carry + /// `data-node-hydration="n[,event:bubbles]..."`, dynamic text is bracketed + /// by `text` and a placeholder is ``. The marker comments are consumed (removed) by the walk, and an + /// empty dynamic text gets a real text node inserted for the id to bind + /// to. The listener suffix of the element marker is IGNORED here: listener + /// registrations arrive as ordinary `new-event-listener` operations later + /// in the same batch, which is the only form that carries the interned + /// name id, and is what lets `mounted` and the observer-backed families + /// work exactly as they do on a fresh mount. + /// + /// Every index in 0..len(ids) must be matched by exactly one marker. A + /// missing, duplicated or out-of-range marker means the served HTML and + /// the running component disagree — a build skew, not a recoverable + /// condition — and the host reports it rather than binding a wrong node. + /// + /// Valid only as the first operation of the first batch, and only when + /// `run` was called with `render-mode.hydrate`; in that mode the guest + /// emits no node-creating operations for the initial render at all. + type hydrate = list; + /// One mutation. Arms whose only operand is an element id carry it bare. variant operation { cache-string(cache-string), @@ -431,6 +461,7 @@ interface mutations { remove-event-listener(event-listener), remove(element-id), push-root(element-id), + hydrate(hydrate), } } @@ -522,6 +553,27 @@ world app { import events; import dom; + /// How the app's initial render meets the mount root. + variant render-mode { + /// The mount root is empty (or its contents are to be discarded): the + /// guest builds the tree from nothing and the host applies the creating + /// operations. + fresh, + /// The mount root already contains this same component's markup, + /// prerendered by `dioxus-ssr`'s `pre-render`. The guest still runs the + /// initial render — element ids are assigned exactly as in `fresh` — + /// but emits no node-creating operations for it. Instead the first + /// batch opens with a `hydrate` operation binding those ids to the + /// nodes already in the document, followed by the listener + /// registrations. Every render after the first is identical in both + /// modes. + /// + /// Hydration is positional: the served HTML must come from this same + /// component at this same initial state. A disagreement surfaces as a + /// host-side error, not as a silent repair. + hydrate, + } + /// Start the app and return the mutation channel: the host receives the /// read end, reads batches of `operation`s from it for the life of the /// instance, and applies them to the mount root. The app's scheduler keeps @@ -558,7 +610,7 @@ world app { /// (Historical: earlier revisions passed this stream to a host import /// `surface.open`, alongside a since-retired synchronous call transport — /// see bench/README.md for the retirement record.) - export run: async func() -> stream; + export run: async func(mode: render-mode) -> stream; /// Dispatch one DOM event to the listener registered on `target` (the /// ElementId carried by new-event-listener) for the interned event name