Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
41 changes: 41 additions & 0 deletions e2e/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,52 @@ async function serveFile(path: string): Promise<Response> {
}
}

/**
* `/hydrate.html?app=<name>` — 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<Response> {
const [shell, markup] = await Promise.all([
Deno.readTextFile(join(harnessDir, "index.html")),
Deno.readTextFile(join(repoRoot, "examples", app, "golden.html")),
]);
const injected = `<div id="app">${markup}</div>
<script>
window.__HYDRATE = true;
for (const el of document.querySelectorAll("#app, #app *")) {
el.setAttribute("data-server-rendered", "1");
}
</script>`;
const html = shell.replace('<div id="app"></div>', injected);
if (html === shell) throw new Error("harness/index.html no longer contains <div id=\"app\"></div>");
return new Response(html, { headers: { "content-type": CONTENT_TYPES[".html"] } });
}

function handler(req: Request): Promise<Response> {
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)) {
Expand Down
111 changes: 111 additions & 0 deletions e2e/tests/hydrate.spec.ts
Original file line number Diff line number Diff line change
@@ -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 `<!--node-id7--><!--#-->`, 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([]);
});
2 changes: 1 addition & 1 deletion examples/counter/golden.html
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<div class="app"><section class="counter"><button id="dec">-</button><span id="count">0</span><button id="inc">+</button><p id="parity" class="even">count is 0</p></section><section class="echo"><input id="draft" value=""/><p id="echo"></p></section><section class="list"><button id="add">add</button><button id="remove">remove</button><ul id="items"><li>alpha</li><li>beta</li></ul></section><form id="form"><input name="who" id="who"/><button id="submit" type="submit">submit</button><p id="submitted">submitted 0 time(s)</p></form></div>
<div class="app" data-node-hydration="0"><section class="counter"><button id="dec" data-node-hydration="1,click:1">-</button><span id="count"><!--node-id2-->0<!--#--></span><button id="inc" data-node-hydration="3,click:1">+</button><p id="parity" class="even" data-node-hydration="4"><!--node-id5-->count is 0<!--#--></p></section><section class="echo"><input id="draft" value="" data-node-hydration="6,input:1"/><p id="echo"><!--node-id7--><!--#--></p></section><section class="list"><button id="add" data-node-hydration="8,click:1">add</button><button id="remove" data-node-hydration="9,click:1">remove</button><ul id="items"><li data-node-hydration="10"><!--node-id11-->alpha<!--#--></li><li data-node-hydration="12"><!--node-id13-->beta<!--#--></li></ul></section><form id="form" data-node-hydration="14,submit:1"><input name="who" id="who"/><button id="submit" type="submit">submit</button><p id="submitted"><!--node-id15-->submitted 0 time(s)<!--#--></p></form></div>
5 changes: 4 additions & 1 deletion fixtures/surface-probe/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,10 @@ thread_local! {
struct Component;

impl Guest for Component {
async fn run() -> wit_bindgen::rt::async_support::StreamReader<Operation> {
// 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<Operation> {
// Create the channel and hand the read end back as `run`'s return
// value (wit: `export run: async func() -> stream<operation>`).
// Nothing is written from this body: a write here would park waiting
Expand Down
17 changes: 16 additions & 1 deletion harness/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -117,9 +120,12 @@ async function main(): Promise<void> {
// 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) });
},
Expand All @@ -137,8 +143,17 @@ async function main(): Promise<void> {
// - 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("<!--node-id")
: () => root.querySelector(mountedSelector) !== null,
);

(globalThis as unknown as { __mounted: boolean }).__mounted = true;
const statusEl = document.getElementById("status");
Expand Down
138 changes: 138 additions & 0 deletions host/src/applier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface OpSink {
removeEventListener(id: number, name: StrRef, bubbles: boolean): void;
remove(id: number): void;
pushRoot(id: number): void;
hydrate(ids: number[]): void;
}

export interface ListenerDelegate {
Expand Down Expand Up @@ -116,6 +117,13 @@ function truthy(val: string | boolean): boolean {
return val === "true" || val === true;
}

// The exact comment markers `dioxus-ssr`'s `pre_render` writes for a dynamic
// text and a placeholder (dioxus-ssr-0.7.9 src/renderer.rs:189,215). Anchored
// so an unrelated comment cannot be misread as a marker.
const TEXT_MARKER = /^node-id(\d+)$/;
const PLACEHOLDER_MARKER = /^placeholder(\d+)$/;
const COMMENT_NODE = 8;

export class DomApplier implements OpSink {
#doc: Document;
#delegate: ListenerDelegate;
Expand Down Expand Up @@ -519,4 +527,134 @@ export class DomApplier implements OpSink {
// ref:core.ts:173-175 pushRoot
this.#stack.push(this.#getNode(id));
}

// -- hydration ------------------------------------------------------------
//
// ref:core.ts:204-291 hydrate_node/hydrate, ported with three deliberate
// divergences (see wit/world.wit's `hydrate` type doc, which is normative
// for the first two):
// 1. the element marker's `,click:1,...` suffix is IGNORED here — no
// listener is attached and no `data-dioxus-id` is set; listener
// registrations arrive as ordinary `new-event-listener` ops later in
// the same batch (the only form carrying the interned name id
// `ListenerDelegate` needs), so `EventDispatcher.add` sets
// `data-dioxus-id` when that op arrives, same as on a fresh mount.
// 2. every index is validated (wit/world.wit's hydrate doc: "the host
// reports it rather than binding a wrong node") instead of upstream's
// unchecked `ids[parseInt(...)]`.
// 3. upstream's TreeWalker loop is contorted because it mutates the DOM
// (removing marker comments, inserting text nodes) WHILE walking.
// Since each marker carries its own index, visit order cannot affect
// correctness here, so we collect every comment node first (a single
// non-mutating walk) and process the collected list after — much
// simpler than threading `continueToNextNode`/`nextSibling` bookkeeping
// through a concurrent mutation.
hydrate(ids: number[]): void {
const root = this.#nodes[0] as Element;
// Which of ids[0..ids.length) has been matched by a marker so far, so
// "unmatched" and "duplicate" can both be detected after the walk.
const matched = new Uint8Array(ids.length);

const checkIndex = (n: number, source: string): void => {
if (!Number.isInteger(n) || n < 0 || n >= ids.length) {
throw new Error(
`DomApplier.hydrate: marker index ${n} (${source}) is out of range for ${ids.length} id(s)`,
);
}
if (matched[n] !== 0) {
throw new Error(`DomApplier.hydrate: marker index ${n} (${source}) is duplicated`);
}
matched[n] = 1;
};

// -- element markers: data-node-hydration="n[,event:bubbles]..." -------
// ref:core.ts:225-232 hydrate's `under instanceof HTMLElement` branch,
// minus the querySelectorAll/self split (querySelectorAll doesn't match
// the root itself, so upstream checks it separately; a single selector
// rooted one level up isn't available to us either, so keep that split).
const elementMarkers: Element[] = [];
if (root.hasAttribute("data-node-hydration")) elementMarkers.push(root);
for (const el of Array.from(root.querySelectorAll("[data-node-hydration]"))) {
elementMarkers.push(el);
}
for (const el of elementMarkers) {
const marker = el.getAttribute("data-node-hydration")!;
// ref:core.ts:206-207 — only the leading index; the rest (listener
// suffix) is divergence (1) above, deliberately unread.
const n = parseInt(marker.split(",")[0], 10);
checkIndex(n, `element marker "${marker}"`);
this.#setNode(ids[n], el);
}

// -- comment markers: <!--node-idN-->text<!--#--> / <!--placeholderN--> -
// 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`);
}
}
}
}
Loading