fix: hydration panic on camelCased elements (#3876)

* fix: hydration panic on camelCased elements
        via namespace-aware tag comparison
* add test for hydration involving camelCase svg

Compare tags case-insensitively for HTML elements and case-sensitively
for namespaced elements (SVG, MathML) to match browser behavior.

Use eq_ignore_ascii_case for HTML namespace comparison

* fix: suppress the incompatible_msrv lint for newer rust versions
* fix: clippy warnings

- Allow incompatible_msrv for PanicInfo type (stable since 1.81.0)
- Allow dead_code for test struct Comp
- Remove unnecessary parentheses in closure

---------

Co-authored-by: Matt "Siyuan" Yan <mattsy1999@gmail.com>
Co-authored-by: WorldSEnder <6527051+WorldSEnder@users.noreply.github.com>
This commit is contained in:
Siyuan Yan 2025-08-08 22:10:44 +09:00 committed by GitHub
parent 21f373b42d
commit 93e862b1d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 131 additions and 10 deletions

View File

@ -45,7 +45,7 @@ impl Component for Child {
let name = format!("I'm {my_name}.");
// Here we emit the callback to the grandparent component, whenever the button is clicked.
let onclick = self.state.child_clicked.reform(move |_| (my_name.clone()));
let onclick = self.state.child_clicked.reform(move |_| my_name.clone());
html! {
<div class="child-body">

View File

@ -454,15 +454,20 @@ impl ToTokens for HtmlElement {
}}
});
#[rustversion::since(1.89)]
#[rustversion::since(1.88)]
fn derive_debug_tag(vtag: &Ident) -> String {
let span = vtag.span().unwrap();
#[allow(clippy::incompatible_msrv)]
{
// the file, line, column methods are stable since 1.88
format!("[{}:{}:{}] ", span.file(), span.line(), span.column())
}
}
#[rustversion::before(1.89)]
#[rustversion::before(1.88)]
fn derive_debug_tag(_: &Ident) -> &'static str {
""
}
let invalid_void_tag_msg_start = derive_debug_tag(&vtag);
let value = value();

View File

@ -16,6 +16,8 @@ use web_sys::{Element, HtmlTextAreaElement as TextAreaElement};
use super::{BNode, BSubtree, DomSlot, Reconcilable, ReconcileTarget};
use crate::html::AnyScope;
#[cfg(feature = "hydration")]
use crate::virtual_dom::vtag::HTML_NAMESPACE;
use crate::virtual_dom::vtag::{
InputFields, TextareaFields, VTagInner, Value, MATHML_NAMESPACE, SVG_NAMESPACE,
};
@ -365,14 +367,29 @@ mod feat_hydration {
);
let el = node.dyn_into::<Element>().expect("expected an element.");
assert_eq!(
el.tag_name().to_lowercase(),
tag_name,
"expected element of kind {}, found {}.",
tag_name,
el.tag_name().to_lowercase(),
);
{
let el_tag_name = el.tag_name();
let parent_namespace = _parent.namespace_uri();
// In HTML namespace (or no namespace), createElement is case-insensitive
// In other namespaces (SVG, MathML), createElementNS is case-sensitive
let should_compare_case_insensitive = parent_namespace.is_none()
|| parent_namespace.as_deref() == Some(HTML_NAMESPACE);
if should_compare_case_insensitive {
// Case-insensitive comparison for HTML elements
assert!(
tag_name.eq_ignore_ascii_case(&el_tag_name),
"expected element of kind {tag_name}, found {el_tag_name}.",
);
} else {
// Case-sensitive comparison for namespaced elements (SVG, MathML)
assert_eq!(
el_tag_name, tag_name,
"expected element of kind {tag_name}, found {el_tag_name}.",
);
}
}
// We simply register listeners and update all attributes.
let attributes = attributes.apply(root, &el);
let listeners = listeners.apply(root, &el);

View File

@ -18,6 +18,7 @@ thread_local! {
/// Unless a panic hook is set through this function, Yew will
/// overwrite any existing panic hook when an application is rendered with [Renderer].
#[cfg(feature = "csr")]
#[allow(clippy::incompatible_msrv)]
pub fn set_custom_panic_hook(hook: Box<dyn Fn(&PanicInfo<'_>) + Sync + Send + 'static>) {
std::panic::set_hook(hook);
PANIC_HOOK_IS_SET.with(|hook_is_set| hook_is_set.set(true));

View File

@ -8,6 +8,7 @@ use crate::html::AnyScope;
use crate::virtual_dom::VNode;
use crate::{scheduler, Component, Context, Html};
#[allow(dead_code)]
struct Comp;
impl Component for Comp {
type Message = ();

View File

@ -1077,3 +1077,100 @@ async fn hydrate_empty() {
let result = obtain_result_by_id("output");
assert_eq!(result.as_str(), r#"<div>after</div><div>after</div>"#);
}
#[wasm_bindgen_test]
async fn hydration_with_camelcase_svg_elements() {
#[function_component]
fn SvgWithCamelCase() -> Html {
html! {
<svg width="100" height="100">
<defs>
<@{"linearGradient"} id="gradient1">
<stop offset="0%" stop-color="red" />
<stop offset="100%" stop-color="blue" />
</@>
<@{"radialGradient"} id="gradient2">
<stop offset="0%" stop-color="yellow" />
<stop offset="100%" stop-color="green" />
</@>
<@{"clipPath"} id="clip1">
<circle cx="50" cy="50" r="40" />
</@>
</defs>
<rect x="10" y="10" width="80" height="80" fill="url(#gradient1)" />
<circle cx="50" cy="50" r="30" fill="url(#gradient2)" clip-path="url(#clip1)" />
<@{"feDropShadow"} dx="2" dy="2" stdDeviation="2" />
</svg>
}
}
#[function_component]
fn App() -> Html {
let counter = use_state(|| 0);
let onclick = {
let counter = counter.clone();
Callback::from(move |_| counter.set(*counter + 1))
};
html! {
<div id="result">
<div class="counter">{"Count: "}{*counter}</div>
<button {onclick} class="increment">{"Increment"}</button>
<SvgWithCamelCase />
</div>
}
}
// Server render
let s = ServerRenderer::<App>::new().render().await;
// Set HTML
gloo::utils::document()
.query_selector("#output")
.unwrap()
.unwrap()
.set_inner_html(&s);
sleep(Duration::ZERO).await;
// Hydrate - this should not panic
Renderer::<App>::with_root(gloo::utils::document().get_element_by_id("output").unwrap())
.hydrate();
sleep(Duration::from_millis(10)).await;
// Verify the SVG elements are present and properly cased
let svg = gloo::utils::document()
.query_selector("svg")
.unwrap()
.unwrap();
let linear_gradient = svg.query_selector("linearGradient").unwrap().unwrap();
assert_eq!(linear_gradient.tag_name(), "linearGradient");
let radial_gradient = svg.query_selector("radialGradient").unwrap().unwrap();
assert_eq!(radial_gradient.tag_name(), "radialGradient");
let clip_path = svg.query_selector("clipPath").unwrap().unwrap();
assert_eq!(clip_path.tag_name(), "clipPath");
// Test interactivity still works after hydration
gloo::utils::document()
.query_selector(".increment")
.unwrap()
.unwrap()
.dyn_into::<HtmlElement>()
.unwrap()
.click();
sleep(Duration::from_millis(10)).await;
let counter_text = gloo::utils::document()
.query_selector(".counter")
.unwrap()
.unwrap()
.text_content()
.unwrap();
assert_eq!(counter_text, "Count: 1");
}