Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0312ee1
fix a few ci failures
ealmloff Mar 10, 2026
672a819
start hydration validation
ealmloff Mar 10, 2026
cbea971
pull out validation logic
ealmloff Mar 18, 2026
9fbec9f
use autofmt
ealmloff Mar 18, 2026
cdef069
fix leading tab
ealmloff Mar 18, 2026
cd00e0b
refactor
ealmloff Mar 18, 2026
4c5893e
more refactoring. only rebuild the component, don't destroy state
ealmloff Mar 18, 2026
0cb89c2
remove hydration state
ealmloff Mar 18, 2026
37dab67
simplify
ealmloff Mar 19, 2026
682c09d
pull out stub
ealmloff Mar 19, 2026
60b83f3
simplify
ealmloff Mar 19, 2026
1dfcbc1
remove cap
ealmloff Mar 19, 2026
1105aeb
shrink diff
ealmloff Mar 20, 2026
a4febac
shrink diff
ealmloff Mar 20, 2026
6bd0a78
remove event diff
ealmloff Mar 20, 2026
97f85f5
more strict validation
ealmloff Mar 20, 2026
826c9cd
revert element changes
ealmloff Mar 20, 2026
7a7ee34
cleanup create_scope_dom and switch to a trait to avoid missing items
ealmloff Apr 17, 2026
867d929
fix core
ealmloff Apr 17, 2026
ebfe9f7
cleanup
ealmloff Apr 17, 2026
bc12942
remove settings and fix clippy
ealmloff Apr 17, 2026
f44ee47
fix style and inner html
ealmloff Apr 17, 2026
79d84ae
fix diffing dangerous inner html
ealmloff Apr 17, 2026
5f60076
revert formatting only changes
ealmloff Apr 20, 2026
9a6b4bb
undo formatting changes
ealmloff Apr 20, 2026
9ce2012
fix rebuild leak
ealmloff Apr 20, 2026
21e63ba
pull out visitors
ealmloff Apr 20, 2026
5fa394c
remove RECOVERY_FRAGMENT_ID
ealmloff Apr 27, 2026
5e8a02a
skip non-placeholder comments in diffing
ealmloff Apr 27, 2026
9068f4d
fix some logic leaking out into normal hydration
ealmloff Apr 30, 2026
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
12 changes: 12 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
12 changes: 6 additions & 6 deletions packages/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
35 changes: 35 additions & 0 deletions packages/core/src/virtual_dom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,11 @@ pub struct VirtualDom {
rx: futures_channel::mpsc::UnboundedReceiver<SchedulerMsg>,
}

/// 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.
///
Expand Down Expand Up @@ -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<usize, CreateScopeDomError> {
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::<NoOpMutations>(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")]
Expand Down
45 changes: 45 additions & 0 deletions packages/core/tests/create_dom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
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
1 change: 0 additions & 1 deletion packages/interpreter/src/unified_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Loading