Fix internally tracked sibling positions earlier when hydrating (#3914)

* fix internally tracked sibling positions earlier

previously, the fixup was done in the first "real" render, now this
happens immediately in the render that transitions from
ComponentRenderState::Hydration to ComponentRenderState::Render.

We do so by threading through a reference to the sibling that needs
a fix

* test case for element with early change in render order
   harden test case by using an extra inner component
This commit is contained in:
WorldSEnder 2025-09-09 12:18:01 +02:00 committed by GitHub
parent b246e0d5ed
commit b7a599b9e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 215 additions and 113 deletions

View File

@ -5,7 +5,7 @@ use std::rc::Rc;
use web_sys::Element;
use crate::dom_bundle::{BSubtree, DomSlot, DynamicDomSlot};
use crate::dom_bundle::{BSubtree, DomSlot};
use crate::html::{BaseComponent, Scope, Scoped};
/// An instance of an application.
@ -34,13 +34,9 @@ where
scope: Scope::new(None),
};
let hosting_root = BSubtree::create_root(&host);
app.scope.mount_in_place(
hosting_root,
host,
DomSlot::at_end(),
DynamicDomSlot::new_debug_trapped(),
props,
);
let _ = app
.scope
.mount_in_place(hosting_root, host, DomSlot::at_end(), props);
app
}
@ -110,15 +106,17 @@ mod feat_hydration {
let mut fragment = Fragment::collect_children(&host);
let hosting_root = BSubtree::create_root(&host);
let mut previous_next_sibling = None;
app.scope.hydrate_in_place(
hosting_root,
host.clone(),
&mut fragment,
DynamicDomSlot::new_debug_trapped(),
Rc::clone(&props),
&mut previous_next_sibling,
);
#[cfg(debug_assertions)] // Fix trapped next_sibling at the root
app.scope.reuse(props, DomSlot::at_end());
if let Some(previous_next_sibling) = previous_next_sibling {
previous_next_sibling.reassign(DomSlot::at_end());
}
// We remove all remaining nodes, this mimics the clear_element behaviour in
// mount_with_props.

View File

@ -63,23 +63,16 @@ impl Reconcilable for VComp {
key,
..
} = self;
let internal_ref = DynamicDomSlot::new_debug_trapped();
let scope = mountable.mount(
root,
parent_scope,
parent.to_owned(),
slot,
internal_ref.clone(),
);
let (scope, internal_ref) = mountable.mount(root, parent_scope, parent.to_owned(), slot);
(
internal_ref.to_position(),
BComp {
type_id,
scope,
own_position: internal_ref,
key,
scope,
},
)
}
@ -131,6 +124,7 @@ mod feat_hydration {
parent_scope: &AnyScope,
parent: &Element,
fragment: &mut Fragment,
prev_next_sibling: &mut Option<DynamicDomSlot>,
) -> Self::Bundle {
let VComp {
type_id,
@ -138,20 +132,19 @@ mod feat_hydration {
key,
..
} = self;
let internal_ref = DynamicDomSlot::new_debug_trapped();
let scoped = mountable.hydrate(
let (scope, own_slot) = mountable.hydrate(
root.clone(),
parent_scope,
parent.clone(),
internal_ref.clone(),
fragment,
prev_next_sibling,
);
BComp {
type_id,
scope: scoped,
own_position: internal_ref,
scope,
own_position: own_slot,
key,
}
}

View File

@ -494,7 +494,7 @@ impl Reconcilable for VList {
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
use crate::dom_bundle::{Fragment, Hydratable};
use crate::dom_bundle::{DynamicDomSlot, Fragment, Hydratable};
impl Hydratable for VList {
fn hydrate(
@ -503,13 +503,14 @@ mod feat_hydration {
parent_scope: &AnyScope,
parent: &Element,
fragment: &mut Fragment,
prev_next_sibling: &mut Option<DynamicDomSlot>,
) -> Self::Bundle {
let (key, fully_keyed, vchildren) = self.split_for_blist();
let mut children = Vec::with_capacity(vchildren.len());
for child in vchildren.into_iter() {
let child = child.hydrate(root, parent_scope, parent, fragment);
let child = child.hydrate(root, parent_scope, parent, fragment, prev_next_sibling);
children.push(child);
}

View File

@ -267,7 +267,7 @@ impl fmt::Debug for BNode {
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
use crate::dom_bundle::{Fragment, Hydratable};
use crate::dom_bundle::{DynamicDomSlot, Fragment, Hydratable};
impl Hydratable for VNode {
fn hydrate(
@ -276,17 +276,20 @@ mod feat_hydration {
parent_scope: &AnyScope,
parent: &Element,
fragment: &mut Fragment,
prev_next_sibling: &mut Option<DynamicDomSlot>,
) -> Self::Bundle {
match self {
VNode::VTag(vtag) => RcExt::unwrap_or_clone(vtag)
.hydrate(root, parent_scope, parent, fragment)
.hydrate(root, parent_scope, parent, fragment, prev_next_sibling)
.into(),
VNode::VText(vtext) => vtext
.hydrate(root, parent_scope, parent, fragment, prev_next_sibling)
.into(),
VNode::VText(vtext) => vtext.hydrate(root, parent_scope, parent, fragment).into(),
VNode::VComp(vcomp) => RcExt::unwrap_or_clone(vcomp)
.hydrate(root, parent_scope, parent, fragment)
.hydrate(root, parent_scope, parent, fragment, prev_next_sibling)
.into(),
VNode::VList(vlist) => RcExt::unwrap_or_clone(vlist)
.hydrate(root, parent_scope, parent, fragment)
.hydrate(root, parent_scope, parent, fragment, prev_next_sibling)
.into(),
// You cannot hydrate a VRef.
VNode::VRef(_) => {
@ -303,9 +306,11 @@ mod feat_hydration {
)
}
VNode::VSuspense(vsuspense) => RcExt::unwrap_or_clone(vsuspense)
.hydrate(root, parent_scope, parent, fragment)
.hydrate(root, parent_scope, parent, fragment, prev_next_sibling)
.into(),
VNode::VRaw(vraw) => vraw
.hydrate(root, parent_scope, parent, fragment, prev_next_sibling)
.into(),
VNode::VRaw(vraw) => vraw.hydrate(root, parent_scope, parent, fragment).into(),
}
}
}

View File

@ -142,7 +142,7 @@ impl Reconcilable for VRaw {
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
use crate::dom_bundle::{Fragment, Hydratable};
use crate::dom_bundle::{DynamicDomSlot, Fragment, Hydratable};
use crate::virtual_dom::Collectable;
impl Hydratable for VRaw {
@ -152,15 +152,24 @@ mod feat_hydration {
_parent_scope: &AnyScope,
parent: &Element,
fragment: &mut Fragment,
prev_next_sibling: &mut Option<DynamicDomSlot>,
) -> Self::Bundle {
let collectable = Collectable::Raw;
let fallback_fragment = Fragment::collect_between(fragment, &collectable, parent);
let first_child = fallback_fragment.iter().next().cloned();
if let (Some(first_child), prev_next_sibling) = (&first_child, prev_next_sibling) {
if let Some(prev_next_sibling) = prev_next_sibling {
prev_next_sibling.reassign(DomSlot::at(first_child.clone()));
}
*prev_next_sibling = None;
}
let Self { html } = self;
BRaw {
children_count: fallback_fragment.len(),
reference: fallback_fragment.iter().next().cloned(),
reference: first_child,
html,
}
}

View File

@ -224,7 +224,7 @@ impl Reconcilable for VSuspense {
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
use crate::dom_bundle::{Fragment, Hydratable};
use crate::dom_bundle::{DynamicDomSlot, Fragment, Hydratable};
use crate::virtual_dom::Collectable;
impl Hydratable for VSuspense {
@ -234,6 +234,7 @@ mod feat_hydration {
parent_scope: &AnyScope,
parent: &Element,
fragment: &mut Fragment,
previous_next_sibling: &mut Option<DynamicDomSlot>,
) -> Self::Bundle {
let detached_parent = document()
.create_element("div")
@ -250,9 +251,13 @@ mod feat_hydration {
// Even if initially suspended, these children correspond to the first non-suspended
// content Refer to VSuspense::render_to_string
let children_bundle =
self.children
.hydrate(root, parent_scope, &detached_parent, &mut nodes);
let children_bundle = self.children.hydrate(
root,
parent_scope,
&detached_parent,
&mut nodes,
previous_next_sibling,
);
// We trim all leading text nodes before checking as it's likely these are whitespaces.
nodes.trim_start_text_nodes();

View File

@ -337,7 +337,7 @@ mod feat_hydration {
use web_sys::Node;
use super::*;
use crate::dom_bundle::{node_type_str, Fragment, Hydratable};
use crate::dom_bundle::{node_type_str, DynamicDomSlot, Fragment, Hydratable};
impl Hydratable for VTag {
fn hydrate(
@ -346,6 +346,7 @@ mod feat_hydration {
parent_scope: &AnyScope,
_parent: &Element,
fragment: &mut Fragment,
prev_next_sibling: &mut Option<DynamicDomSlot>,
) -> Self::Bundle {
let tag_name = self.tag().to_owned();
@ -412,7 +413,12 @@ mod feat_hydration {
}
VTagInner::Other { children, tag } => {
let mut nodes = Fragment::collect_children(&el);
let child_bundle = children.hydrate(root, parent_scope, &el, &mut nodes);
let mut prev_next_child = None;
let child_bundle =
children.hydrate(root, parent_scope, &el, &mut nodes, &mut prev_next_child);
if let Some(prev_next_child) = prev_next_child {
prev_next_child.reassign(DomSlot::at_end());
}
nodes.trim_start_text_nodes();
@ -423,6 +429,10 @@ mod feat_hydration {
};
node_ref.set(Some((*el).clone()));
if let Some(prev_next_sibling) = prev_next_sibling {
prev_next_sibling.reassign(DomSlot::at((*el).clone()));
}
*prev_next_sibling = None;
BTag {
inner,

View File

@ -92,7 +92,7 @@ mod feat_hydration {
use web_sys::Node;
use super::*;
use crate::dom_bundle::{Fragment, Hydratable};
use crate::dom_bundle::{DynamicDomSlot, Fragment, Hydratable};
impl Hydratable for VText {
fn hydrate(
@ -101,9 +101,19 @@ mod feat_hydration {
_parent_scope: &AnyScope,
parent: &Element,
fragment: &mut Fragment,
previous_next_sibling: &mut Option<DynamicDomSlot>,
) -> Self::Bundle {
let next_sibling = if let Some(m) = fragment.front().cloned() {
// better safe than sorry.
let create_at = |next_sibling: Option<Node>, text: AttrValue| {
// If there are multiple text nodes placed back-to-back in SSR, it may be parsed as
// a single text node by browser, hence we need to add extra text
// nodes here if the next node is not a text node. Similarly, the
// value of the text node may be a combination of multiple VText
// vnodes. So we always need to override their values.
let text_node = document().create_text_node(text.as_ref());
DomSlot::create(next_sibling).insert(parent, &text_node);
BText { text, text_node }
};
let btext = if let Some(m) = fragment.front().cloned() {
if m.node_type() == Node::TEXT_NODE {
let m = m.unchecked_into::<TextNode>();
// pop current node.
@ -117,27 +127,21 @@ mod feat_hydration {
// Please see the next comment for a detailed explanation.
m.set_node_value(Some(self.text.as_ref()));
return BText {
BText {
text: self.text,
text_node: m,
};
}
} else {
create_at(Some(m), self.text)
}
Some(m)
} else {
fragment.sibling_at_end().cloned()
create_at(fragment.sibling_at_end().cloned(), self.text)
};
// If there are multiple text nodes placed back-to-back in SSR, it may be parsed as a
// single text node by browser, hence we need to add extra text nodes here
// if the next node is not a text node. Similarly, the value of the text
// node may be a combination of multiple VText vnodes. So we always need to
// override their values.
let text_node = document().create_text_node("");
DomSlot::create(next_sibling).insert(parent, &text_node);
BText {
text: "".into(),
text_node,
if let Some(previous_next_sibling) = previous_next_sibling {
previous_next_sibling.reassign(DomSlot::at(btext.text_node.clone().into()));
}
*previous_next_sibling = None;
btext
}
}
}

View File

@ -94,8 +94,9 @@ mod feat_hydration {
parent: &Element,
fragment: &mut Fragment,
node: VNode,
previous_next_sibling: &mut Option<DynamicDomSlot>,
) -> Self {
let bundle = node.hydrate(root, parent_scope, parent, fragment);
let bundle = node.hydrate(root, parent_scope, parent, fragment, previous_next_sibling);
Self(bundle)
}
}

View File

@ -54,6 +54,7 @@ mod trap_impl {
static TRAP: Node = gloo::utils::document().create_element("div").unwrap().into();
}
/// Get a "trap" node, or None if compiled without debug_assertions
#[cfg(feature = "hydration")]
pub fn get_trap_node() -> Option<Node> {
#[cfg(debug_assertions)]
{
@ -98,6 +99,7 @@ impl DomSlot {
/// A new "placeholder" [DomSlot] that should not be used to insert nodes
#[inline]
#[cfg(feature = "hydration")]
pub fn new_debug_trapped() -> Self {
Self::create(trap_impl::get_trap_node())
}
@ -165,10 +167,19 @@ impl DynamicDomSlot {
}
}
#[cfg(feature = "hydration")]
pub fn new_debug_trapped() -> Self {
Self::new(DomSlot::new_debug_trapped())
}
/// Move out of self, leaving behind a trapped slot. `self` should not be used afterwards.
/// Used during the transition from a hydrating to a rendered component to move state between
/// enum variants.
#[cfg(feature = "hydration")]
pub fn take(&mut self) -> Self {
std::mem::replace(self, Self::new(DomSlot::new_debug_trapped()))
}
/// Change the [`DomSlot`] that is targeted. Subsequently, this will behave as if `self` was
/// created from the passed DomSlot in the first place.
pub fn reassign(&self, next_position: DomSlot) {

View File

@ -100,7 +100,7 @@ pub(super) trait Reconcilable {
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
use crate::dom_bundle::Fragment;
use crate::dom_bundle::{DynamicDomSlot, Fragment};
pub(in crate::dom_bundle) trait Hydratable: Reconcilable {
/// hydrates current tree.
@ -116,6 +116,12 @@ mod feat_hydration {
parent_scope: &AnyScope,
parent: &Element,
fragment: &mut Fragment,
// We hydrate in document order, but need to know the "next sibling" in each component
// to shift elements. (blame Web API for having `Node.insertBefore` but no
// `Node.insertAfter`) Hence, we pass an optional argument to inform of the
// new hydrated node's position. This should end up assigning the same
// position that would have been returned from `Self::attach` on creation.
prev_next_sibling: &mut Option<DynamicDomSlot>,
) -> Self::Bundle;
}
}

View File

@ -25,10 +25,13 @@ pub(crate) enum ComponentRenderState {
bundle: Bundle,
root: BSubtree,
parent: Element,
/// The dom position in front of the next sibling
/// The dom position in front of the next sibling.
/// Gets updated when the bundle in which this component occurs gets re-rendered and is
/// shared with the children of this component.
sibling_slot: DynamicDomSlot,
/// The dom position in front of this component. Adjusted whenever this component
/// re-renders.
/// The dom position in front of this component.
/// Gets updated whenever this component re-renders and is shared with the bundle in which
/// this component occurs.
own_slot: DynamicDomSlot,
},
#[cfg(feature = "hydration")]
@ -106,10 +109,9 @@ impl ComponentRenderState {
sibling_slot,
..
} => {
bundle.shift(&next_parent, next_slot.clone());
*parent = next_parent;
sibling_slot.reassign(next_slot);
bundle.shift(parent, sibling_slot.to_position());
}
#[cfg(feature = "hydration")]
Self::Hydration {
@ -118,10 +120,9 @@ impl ComponentRenderState {
sibling_slot,
..
} => {
fragment.shift(&next_parent, next_slot.clone());
*parent = next_parent;
sibling_slot.reassign(next_slot);
fragment.shift(parent, sibling_slot.to_position());
}
#[cfg(feature = "ssr")]
@ -228,6 +229,9 @@ pub(crate) struct ComponentState {
#[cfg(feature = "csr")]
has_rendered: bool,
/// This deals with an edge case. Usually, we want to update props as fast as possible.
/// But, when a component hydrates and suspends, we want to continue using the intially given
/// props. This is prop updates are ignored during SSR, too.
#[cfg(feature = "hydration")]
pending_props: Option<Rc<dyn Any>>,
@ -481,7 +485,7 @@ impl ComponentState {
}
}
fn commit_render(&mut self, shared_state: &Shared<Option<ComponentState>>, new_root: Html) {
fn commit_render(&mut self, shared_state: &Shared<Option<ComponentState>>, new_vdom: Html) {
// Currently not suspended, we remove any previous suspension and update
// normally.
self.resume_existing_suspension();
@ -499,7 +503,7 @@ impl ComponentState {
let scope = self.inner.any_scope();
let new_node_ref =
bundle.reconcile(root, &scope, parent, sibling_slot.to_position(), new_root);
bundle.reconcile(root, &scope, parent, sibling_slot.to_position(), new_vdom);
own_slot.reassign(new_node_ref);
let first_render = !self.has_rendered;
@ -523,8 +527,9 @@ impl ComponentState {
ref mut sibling_slot,
ref root,
} => {
// We schedule a "first" render to run immediately after hydration,
// to fix NodeRefs (first_node and slot).
// We schedule a "first" render to run immediately after hydration.
// Most notably, only this render will trigger the "rendered" callback, hence we
// want to prioritize this.
scheduler::push_component_priority_render(
self.comp_id,
Box::new(RenderRunner {
@ -533,26 +538,25 @@ impl ComponentState {
);
let scope = self.inner.any_scope();
// This first node is not guaranteed to be correct here.
// As it may be a comment node that is removed afterwards.
// but we link it anyways.
let bundle = Bundle::hydrate(root, &scope, parent, fragment, new_root);
let bundle = Bundle::hydrate(
root,
&scope,
parent,
fragment,
new_vdom,
&mut Some(own_slot.clone()),
);
// We trim all text nodes before checking as it's likely these are whitespaces.
fragment.trim_start_text_nodes();
assert!(fragment.is_empty(), "expected end of component, found node");
self.render_state = ComponentRenderState::Render {
root: root.clone(),
bundle,
parent: parent.clone(),
own_slot: std::mem::replace(own_slot, DynamicDomSlot::new_debug_trapped()),
sibling_slot: std::mem::replace(
sibling_slot,
DynamicDomSlot::new_debug_trapped(),
),
own_slot: own_slot.take(),
sibling_slot: sibling_slot.take(),
};
}
@ -560,7 +564,7 @@ impl ComponentState {
ComponentRenderState::Ssr { ref mut sender } => {
let _ = shared_state;
if let Some(tx) = sender.take() {
tx.send(new_root).unwrap();
tx.send(new_vdom).unwrap();
}
}
};
@ -875,13 +879,7 @@ mod tests {
let lifecycle = props.lifecycle.clone();
lifecycle.borrow_mut().clear();
scope.mount_in_place(
root,
parent,
DomSlot::at_end(),
DynamicDomSlot::new_debug_trapped(),
Rc::new(props),
);
let _ = scope.mount_in_place(root, parent, DomSlot::at_end(), Rc::new(props));
crate::scheduler::start_now();
assert_eq!(&lifecycle.borrow_mut().deref()[..], expected);

View File

@ -547,17 +547,17 @@ mod feat_csr {
root: BSubtree,
parent: Element,
slot: DomSlot,
internal_ref: DynamicDomSlot,
props: Rc<COMP::Properties>,
) {
) -> DynamicDomSlot {
let bundle = Bundle::new();
let sibling_slot = DynamicDomSlot::new(slot);
internal_ref.reassign(sibling_slot.to_position());
let own_slot = DynamicDomSlot::new(sibling_slot.to_position());
let shared_slot = own_slot.clone();
let state = ComponentRenderState::Render {
bundle,
root,
own_slot: internal_ref,
own_slot,
parent,
sibling_slot,
};
@ -577,6 +577,7 @@ mod feat_csr {
);
// Not guaranteed to already have the scheduler started
scheduler::start();
shared_slot
}
pub(crate) fn reuse(&self, props: Rc<COMP::Properties>, slot: DomSlot) {
@ -653,7 +654,7 @@ mod feat_hydration {
{
/// Hydrates the component.
///
/// Returns a pending NodeRef of the next sibling.
/// Returns the position of the hydrated node in DOM.
///
/// # Note
///
@ -664,9 +665,9 @@ mod feat_hydration {
root: BSubtree,
parent: Element,
fragment: &mut Fragment,
internal_ref: DynamicDomSlot,
props: Rc<COMP::Properties>,
) {
prev_next_sibling: &mut Option<DynamicDomSlot>,
) -> DynamicDomSlot {
// This is very helpful to see which component is failing during hydration
// which means this component may not having a stable layout / differs between
// client-side and server-side.
@ -693,11 +694,22 @@ mod feat_hydration {
_ => None,
};
// These two references need to be fixed before the component is used.
// Our own slot gets fixed on the first render.
// The sibling slot gets shared with the caller to fix up when continuing through
// existing DOM.
let own_slot = DynamicDomSlot::new_debug_trapped();
let shared_slot = own_slot.clone();
let sibling_slot = DynamicDomSlot::new_debug_trapped();
if let Some(prev_next_sibling) = prev_next_sibling {
prev_next_sibling.reassign(shared_slot.to_position());
}
*prev_next_sibling = Some(sibling_slot.clone());
let state = ComponentRenderState::Hydration {
parent,
root,
own_slot: internal_ref,
sibling_slot: DynamicDomSlot::new_debug_trapped(),
own_slot,
sibling_slot,
fragment,
};
@ -716,6 +728,7 @@ mod feat_hydration {
// Not guaranteed to already have the scheduler started
scheduler::start();
shared_slot
}
}
}

View File

@ -65,8 +65,7 @@ pub(crate) trait Mountable {
parent_scope: &AnyScope,
parent: Element,
slot: DomSlot,
internal_ref: DynamicDomSlot,
) -> Box<dyn Scoped>;
) -> (Box<dyn Scoped>, DynamicDomSlot);
#[cfg(feature = "csr")]
fn reuse(self: Box<Self>, scope: &dyn Scoped, slot: DomSlot);
@ -86,9 +85,9 @@ pub(crate) trait Mountable {
root: BSubtree,
parent_scope: &AnyScope,
parent: Element,
internal_ref: DynamicDomSlot,
fragment: &mut Fragment,
) -> Box<dyn Scoped>;
prev_next_sibling: &mut Option<DynamicDomSlot>,
) -> (Box<dyn Scoped>, DynamicDomSlot);
}
pub(crate) struct PropsWrapper<COMP: BaseComponent> {
@ -127,12 +126,11 @@ impl<COMP: BaseComponent> Mountable for PropsWrapper<COMP> {
parent_scope: &AnyScope,
parent: Element,
slot: DomSlot,
internal_ref: DynamicDomSlot,
) -> Box<dyn Scoped> {
) -> (Box<dyn Scoped>, DynamicDomSlot) {
let scope: Scope<COMP> = Scope::new(Some(parent_scope.clone()));
scope.mount_in_place(root.clone(), parent, slot, internal_ref, self.props);
let own_slot = scope.mount_in_place(root.clone(), parent, slot, self.props);
Box::new(scope)
(Box::new(scope), own_slot)
}
#[cfg(feature = "csr")]
@ -165,13 +163,14 @@ impl<COMP: BaseComponent> Mountable for PropsWrapper<COMP> {
root: BSubtree,
parent_scope: &AnyScope,
parent: Element,
internal_ref: DynamicDomSlot,
fragment: &mut Fragment,
) -> Box<dyn Scoped> {
prev_next_sibling: &mut Option<DynamicDomSlot>,
) -> (Box<dyn Scoped>, DynamicDomSlot) {
let scope: Scope<COMP> = Scope::new(Some(parent_scope.clone()));
scope.hydrate_in_place(root, parent, fragment, internal_ref, self.props);
let own_slot =
scope.hydrate_in_place(root, parent, fragment, self.props, prev_next_sibling);
Box::new(scope)
(Box::new(scope), own_slot)
}
}

View File

@ -1078,6 +1078,55 @@ async fn hydrate_empty() {
assert_eq!(result.as_str(), r#"<div>after</div><div>after</div>"#);
}
#[wasm_bindgen_test]
async fn hydrate_flicker() {
// This components renders the same on the server and client during the first render,
// but then immediately changes the order of keyed elements in the next render.
// This should not lead to any hydration failures
#[derive(Properties, PartialEq)]
struct InnerCompProps {
text: String,
}
#[component]
fn InnerComp(InnerCompProps { text }: &InnerCompProps) -> Html {
html! { <p>{text.clone()}</p> }
}
#[component]
fn Flickering() -> Html {
let trigger = use_state(|| false);
let is_first = !*trigger;
if is_first {
trigger.set(true);
html! {
<>
<InnerComp key="1" text="1" />
<InnerComp key="2" text="2" />
</>
}
} else {
html! {
<>
<InnerComp key="2" text="2" />
<InnerComp key="1" text="1" />
</>
}
}
}
let s = ServerRenderer::<Flickering>::new().render().await;
let output_element = gloo::utils::document()
.query_selector("#output")
.unwrap()
.unwrap();
output_element.set_inner_html(&s);
Renderer::<Flickering>::with_root(output_element).hydrate();
sleep(Duration::from_millis(50)).await;
let result = obtain_result_by_id("output");
assert_eq!(result.as_str(), r#"<p>2</p><p>1</p>"#);
}
#[wasm_bindgen_test]
async fn hydration_with_camelcase_svg_elements() {
#[function_component]