Skip to content
Draft
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
11 changes: 11 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ members = [
"packages/playwright-tests/fullstack-spread",
"packages/playwright-tests/fullstack-routing",
"packages/playwright-tests/fullstack-hydration-order",
"packages/playwright-tests/fullstack-hydration-recovery",
"packages/playwright-tests/suspense-carousel",
"packages/playwright-tests/nested-suspense",
"packages/playwright-tests/cli-optimization",
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/virtual_dom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,26 @@ impl VirtualDom {
to.append_children(ElementId(0), m);
}

/// Emit creation mutations for a scope's existing vdom tree without
/// re-running the component.
///
/// This is used for hydration mismatch recovery: the vdom tree is already
/// built, but the DOM nodes were never created because hydration failed.
/// This walks the existing `last_rendered_node` and emits the mutations
/// needed to create real DOM nodes for it.
///
/// Returns the number of nodes created on the stack (for use with
/// [`WriteMutations::append_children`]).
pub fn create_scope_dom(&mut self, to: &mut impl WriteMutations, scope_id: ScopeId) -> usize {
let _runtime = RuntimeGuard::new(self.runtime.clone());
let existing_nodes = self.scopes[scope_id.0]
.last_rendered_node
.clone()
.expect("scope should have rendered nodes during hydration");

self.create_scope(Some(to), scope_id, existing_nodes, None)
}

/// Render whatever the VirtualDom has ready as fast as possible without requiring an executor to progress
/// suspended subtrees.
#[instrument(skip(self, to), level = "trace", name = "VirtualDom::render_immediate")]
Expand Down
1 change: 1 addition & 0 deletions packages/dioxus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ macro = ["dep:dioxus-core-macro"]
html = ["dep:dioxus-html"]
hooks = ["dep:dioxus-hooks"]
devtools = ["dep:dioxus-devtools", "dioxus-web?/devtools"]
debug-hydration-validation = ["dioxus-web?/debug-hydration-validation"]
mounted = ["dioxus-web?/mounted"]
asset = ["dep:manganis", "dep:dioxus-asset-resolver"]
document = ["dioxus-web?/document", "dep:dioxus-document", "dep:dioxus-history"]
Expand Down
196 changes: 196 additions & 0 deletions packages/playwright-tests/fullstack-hydration-recovery.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// @ts-check
const { test, expect } = require("@playwright/test");

const SERVER_URL = "http://localhost:7978";
const HYDRATION_MISMATCH_MESSAGE = "[HYDRATION MISMATCH]";
const HYDRATION_RECOVERY_MESSAGE =
"Rebuilding subtree.";

async function waitForBuild(request) {
for (let i = 0; i < 30; i++) {
const response = await request.get(SERVER_URL);
const text = await response.text();
if (response.status() === 200 && text.includes('id="recovery-button"')) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}

throw new Error("Timed out waiting for the hydration recovery fixture to build");
}

test("hydration mismatch recovers nested structure, text, attributes, and placeholders", async ({
page,
request,
}) => {
await waitForBuild(request);

const serverResponse = await request.get(SERVER_URL);
expect(serverResponse.status()).toBe(200);

const serverHtml = await serverResponse.text();
expect(serverHtml).toContain('id="recovery-button"');
expect(serverHtml).toMatch(/<div\b[^>]*id="recovery-button"/);
expect(serverHtml).not.toMatch(/<button\b[^>]*id="recovery-button"/);
expect(serverHtml).toContain("Server text content");
expect(serverHtml).toContain("Server placeholder content");
expect(serverHtml).toContain('title="Server value title"');
expect(serverHtml).toContain('data-side="server"');
expect(serverHtml).toContain('id="server-extra-node"');
expect(serverHtml).not.toContain('role="status"');
expect(serverHtml).not.toContain('title="Client attribute title"');

const consoleMessages = [];
const consoleErrors = [];
const pageErrors = [];

page.on("console", (msg) => {
consoleMessages.push(msg.text());
if (msg.type() === "error") {
consoleErrors.push(msg.text());
}
});
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});

await page.goto(SERVER_URL, { waitUntil: "domcontentloaded" });
await expect(page.locator("#streaming-fallback")).toHaveText("Loading streaming…");
await page.waitForLoadState("networkidle");

const mismatchMessages = () =>
consoleMessages.filter((message) =>
message.includes(HYDRATION_MISMATCH_MESSAGE),
);
const hasMismatch = (...fragments) =>
mismatchMessages().some((message) =>
fragments.every((fragment) => message.includes(fragment)),
);

await expect
.poll(() => mismatchMessages().length, {
message: "expected hydration mismatches to be logged in debug builds",
})
.toBeGreaterThan(0);
await expect
.poll(
() => consoleMessages.some((message) => message.includes(HYDRATION_RECOVERY_MESSAGE)),
{ message: "expected hydration recovery to be logged" },
)
.toBeTruthy();

expect(
mismatchMessages().every(
(message) =>
message.includes("Reason:") &&
message.includes("--- expected") &&
message.includes("+++ actual") &&
message.includes("@@"),
),
).toBeTruthy();

expect(
hasMismatch(
"Reason: Expected <button>, found <div>.",
),
).toBeTruthy();
expect(
hasMismatch(
"Reason: Expected <strong>, found <span>.",
),
).toBeTruthy();
expect(
hasMismatch(
'Reason: Expected text "Client text content", found text "Server text content".',
),
).toBeTruthy();
expect(
hasMismatch(
"the DOM is missing [role, title]",
),
).toBeTruthy();
expect(
hasMismatch(
"Reason: Expected placeholder (comment node), found node type 1.",
),
).toBeTruthy();
expect(
hasMismatch(
"these values differ [data-side: expected \"client\", found \"server\", title: expected \"Client value title\", found \"Server value title\"]",
),
).toBeTruthy();
expect(
hasMismatch(
'Reason: Expected text " Client whitespace content ", found text "Client whitespace content".',
),
).toBeTruthy();
expect(
hasMismatch(
"Expected no additional child nodes",
"Server extra node",
),
).toBeTruthy();
expect(
hasMismatch(
"Reason: Expected <button>, found <div>.",
"Streaming mismatch",
),
).toBeTruthy();

const recoveryButton = page.locator("#recovery-button");
await expect(recoveryButton).toHaveCount(1);
await expect(recoveryButton).toHaveJSProperty("tagName", "BUTTON");
await expect(recoveryButton).toHaveText("Recovered 0");

const nestedLeaf = page.locator("#nested-leaf");
await expect(nestedLeaf).toHaveCount(1);
await expect(nestedLeaf).toHaveJSProperty("tagName", "STRONG");
await expect(nestedLeaf).toHaveText("Nested client leaf");

const textMismatch = page.locator("#text-mismatch");
await expect(textMismatch).toHaveText("Client text content");

const attributeMismatch = page.locator("#attribute-mismatch");
await expect(attributeMismatch).toHaveAttribute("role", "status");
await expect(attributeMismatch).toHaveAttribute(
"title",
"Client attribute title",
);

const attributeValueMismatch = page.locator("#attribute-value-mismatch");
await expect(attributeValueMismatch).toHaveAttribute(
"title",
"Client value title",
);
await expect(attributeValueMismatch).toHaveAttribute("data-side", "client");

const whitespaceMismatch = page.locator("#whitespace-mismatch");
await expect.poll(() => whitespaceMismatch.evaluate((node) => node.textContent)).toBe(
" Client whitespace content ",
);

await expect(page.locator("#server-extra-node")).toHaveCount(0);
await expect(page.locator("#extra-node-stable")).toHaveText("Shared child");

await expect(page.locator("#placeholder-mismatch-shell p")).toHaveCount(0);
await expect(page.locator("body")).not.toContainText("Server text content");
await expect(page.locator("body")).not.toContainText(
"Server placeholder content",
);
await expect(page.locator("body")).not.toContainText("Server extra node");

await recoveryButton.click();
await expect(recoveryButton).toHaveText("Recovered 1");
await recoveryButton.click();
await expect(recoveryButton).toHaveText("Recovered 2");

// Streaming mismatch recovery: the suspense boundary resolved from the
// server, the client detected a tag mismatch, and rebuilt the subtree.
const streamingMismatch = page.locator("#streaming-mismatch");
await expect(streamingMismatch).toHaveCount(1);
await expect(streamingMismatch).toHaveJSProperty("tagName", "BUTTON");
await expect(streamingMismatch).toContainText("Streaming client: streamed data");

expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "dioxus-playwright-fullstack-hydration-recovery-test"
version = "0.1.0"
edition = "2021"
publish = false

[dependencies]
dioxus = { workspace = true, features = ["fullstack", "debug-hydration-validation"] }
async-std = "1"

[features]
default = []
server = ["dioxus/server"]
web = ["dioxus/web"]
Loading