diff --git a/Cargo.lock b/Cargo.lock index 2ec09a0d0f..4186bd0572 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6222,6 +6222,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "dioxus-playwright-fullstack-hydration-recovery-test" +version = "0.1.0" +dependencies = [ + "async-std", + "dioxus", +] + [[package]] name = "dioxus-playwright-fullstack-mounted-test" version = "0.1.0" @@ -6516,6 +6524,7 @@ version = "0.7.3" dependencies = [ "ciborium", "dioxus", + "dioxus-autofmt", "dioxus-cli-config", "dioxus-core", "dioxus-core-types", @@ -6525,6 +6534,8 @@ dependencies = [ "dioxus-history", "dioxus-html", "dioxus-interpreter-js", + "dioxus-rsx", + "dioxus-rsx-rosetta", "dioxus-signals", "dioxus-ssr", "dioxus-web", @@ -6540,6 +6551,7 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", + "syn 2.0.114", "tracing", "tracing-wasm", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 1ce425651e..81f34b8b01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/packages/core/src/lib.rs b/packages/core/src/lib.rs index 896dbff0b3..8aeb394a1f 100644 --- a/packages/core/src/lib.rs +++ b/packages/core/src/lib.rs @@ -99,12 +99,12 @@ pub use crate::innerlude::{ remove_future, schedule_update, schedule_update_any, spawn, spawn_forever, spawn_isomorphic, suspend, throw_error, try_consume_context, use_after_render, use_before_render, use_drop, use_hook, use_hook_with_cleanup, with_owner, AnyValue, AnyhowContext, Attribute, - AttributeValue, Callback, CapturedError, Component, ComponentFunction, DynamicNode, Element, - ElementId, ErrorBoundary, ErrorContext, Event, EventHandler, Fragment, HasAttributes, - IntoAttributeValue, IntoDynNode, LaunchConfig, ListenerCallback, MarkerWrapper, Mutation, - Mutations, NoOpMutations, OptionStringFromMarker, Properties, ReactiveContext, RenderError, - Result, Runtime, RuntimeGuard, ScopeId, ScopeState, SpawnIfAsync, SubscriberList, Subscribers, - SuperFrom, SuperInto, SuspendedFuture, SuspenseBoundary, SuspenseBoundaryProps, + AttributeValue, Callback, CapturedError, Component, ComponentFunction, CreateScopeDomError, + DynamicNode, Element, ElementId, ErrorBoundary, ErrorContext, Event, EventHandler, Fragment, + HasAttributes, IntoAttributeValue, IntoDynNode, LaunchConfig, ListenerCallback, MarkerWrapper, + Mutation, Mutations, NoOpMutations, OptionStringFromMarker, Properties, ReactiveContext, + RenderError, Result, Runtime, RuntimeGuard, ScopeId, ScopeState, SpawnIfAsync, SubscriberList, + Subscribers, SuperFrom, SuperInto, SuspendedFuture, SuspenseBoundary, SuspenseBoundaryProps, SuspenseContext, Task, Template, TemplateAttribute, TemplateNode, VComponent, VNode, VNodeInner, VPlaceholder, VText, VirtualDom, WriteMutations, }; diff --git a/packages/core/src/virtual_dom.rs b/packages/core/src/virtual_dom.rs index bc1fced02f..b1001310d4 100644 --- a/packages/core/src/virtual_dom.rs +++ b/packages/core/src/virtual_dom.rs @@ -215,6 +215,11 @@ pub struct VirtualDom { rx: futures_channel::mpsc::UnboundedReceiver, } +/// An error that can occur when trying to create DOM nodes for an existing scope's vdom tree. This can only occur +/// if the scope has never been rendered before, so it has no cached vdom tree to walk. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CreateScopeDomError; + impl VirtualDom { /// Create a new VirtualDom with a component that does not have special props. /// @@ -593,6 +598,36 @@ 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, + ) -> Result { + let _runtime = RuntimeGuard::new(self.runtime.clone()); + let existing_nodes = self.scopes[scope_id.0] + .last_rendered_node + .clone() + .ok_or(CreateScopeDomError)?; + + // The prior pass (e.g. a rebuild with skip_mutations run to populate + // state before hydrating) already claimed element ids. Reclaim them + // before re-creating so the second pass reuses slab slots instead of + // leaking one per element. Component state is preserved. + existing_nodes.remove_node_inner::(self, None, false, None); + + Ok(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")] diff --git a/packages/core/tests/create_dom.rs b/packages/core/tests/create_dom.rs index 009cad65d7..242dc5e485 100644 --- a/packages/core/tests/create_dom.rs +++ b/packages/core/tests/create_dom.rs @@ -184,3 +184,48 @@ fn anchors() { ] ) } + +/// Regression test: `create_scope_dom` is used by hydration mismatch recovery to +/// emit creation mutations for a scope whose vdom tree already exists (because +/// `rebuild` was run with skip_mutations to populate state before hydrating). +/// +/// The second pass must reuse the element ids that were claimed on the first +/// pass instead of allocating fresh ids — otherwise the arena grows by one slot +/// per element on every recovery and the mutations emitted reference ids that +/// drift from the ones a fresh rebuild would have produced. +#[test] +fn create_scope_dom_reuses_element_ids() { + fn app() -> Element { + rsx! { + div { + id: "app", + "hello" + button { onclick: |_| {}, "click" } + div { "child" } + } + } + } + + // Baseline: fresh rebuild's mutations. + let mut baseline = VirtualDom::new(app); + let baseline_edits = baseline.rebuild_to_vec(); + + // Recovery path: rebuild with NoOp (as the web hydration flow does with + // skip_mutations), then emit creation mutations via `create_scope_dom`. + let mut recovered = VirtualDom::new(app); + recovered.rebuild(&mut dioxus_core::NoOpMutations); + let mut recovered_edits = dioxus_core::Mutations::default(); + let m = recovered + .create_scope_dom(&mut recovered_edits, ScopeId::ROOT) + .expect("scope has a rendered tree"); + recovered_edits.edits.push(AppendChildren { + m, + id: ElementId(0), + }); + + assert_eq!( + recovered_edits.edits, baseline_edits.edits, + "create_scope_dom after a skip_mutations rebuild must emit the same \ + mutations (including element ids) as a fresh rebuild" + ); +} diff --git a/packages/dioxus/Cargo.toml b/packages/dioxus/Cargo.toml index 5c10fdcffe..943f953929 100644 --- a/packages/dioxus/Cargo.toml +++ b/packages/dioxus/Cargo.toml @@ -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"] diff --git a/packages/interpreter/src/unified_bindings.rs b/packages/interpreter/src/unified_bindings.rs index 3c17ab52b8..189636fca6 100644 --- a/packages/interpreter/src/unified_bindings.rs +++ b/packages/interpreter/src/unified_bindings.rs @@ -131,7 +131,6 @@ mod js { "{let node = this.templates[$tmpl_id$][$index$].cloneNode(true); this.nodes[$id$] = node; this.stack.push(node);}" } - #[cfg(feature = "binary-protocol")] fn append_children_to_top(many: u16) { "{ let root = this.stack[this.stack.length-many-1]; diff --git a/packages/playwright-tests/fullstack-hydration-recovery.spec.js b/packages/playwright-tests/fullstack-hydration-recovery.spec.js new file mode 100644 index 0000000000..1a9bfbbed0 --- /dev/null +++ b/packages/playwright-tests/fullstack-hydration-recovery.spec.js @@ -0,0 +1,252 @@ +// @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).toContain('id="after-streaming-boundary"'); + expect(serverHtml).toMatch(/]*id="recovery-button"/); + expect(serverHtml).not.toMatch(/]*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("Shared inner html"); + expect(serverHtml).toContain("Server dangerous inner html"); + expect(serverHtml).toContain('style="width:100px;height:40px;"'); + 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