Skip to content

Commit 93e862b

Browse files
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>
1 parent 21f373b commit 93e862b

File tree

6 files changed

+131
-10
lines changed

6 files changed

+131
-10
lines changed

examples/communication_grandchild_with_grandparent/src/child.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ impl Component for Child {
4545
let name = format!("I'm {my_name}.");
4646

4747
// Here we emit the callback to the grandparent component, whenever the button is clicked.
48-
let onclick = self.state.child_clicked.reform(move |_| (my_name.clone()));
48+
let onclick = self.state.child_clicked.reform(move |_| my_name.clone());
4949

5050
html! {
5151
<div class="child-body">

packages/yew-macro/src/html_tree/html_element.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,15 +454,20 @@ impl ToTokens for HtmlElement {
454454
}}
455455
});
456456

457-
#[rustversion::since(1.89)]
457+
#[rustversion::since(1.88)]
458458
fn derive_debug_tag(vtag: &Ident) -> String {
459459
let span = vtag.span().unwrap();
460+
#[allow(clippy::incompatible_msrv)]
461+
{
462+
// the file, line, column methods are stable since 1.88
460463
format!("[{}:{}:{}] ", span.file(), span.line(), span.column())
464+
}
461465
}
462-
#[rustversion::before(1.89)]
466+
#[rustversion::before(1.88)]
463467
fn derive_debug_tag(_: &Ident) -> &'static str {
464468
""
465469
}
470+
466471
let invalid_void_tag_msg_start = derive_debug_tag(&vtag);
467472

468473
let value = value();

packages/yew/src/dom_bundle/btag/mod.rs

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ use web_sys::{Element, HtmlTextAreaElement as TextAreaElement};
1616

1717
use super::{BNode, BSubtree, DomSlot, Reconcilable, ReconcileTarget};
1818
use crate::html::AnyScope;
19+
#[cfg(feature = "hydration")]
20+
use crate::virtual_dom::vtag::HTML_NAMESPACE;
1921
use crate::virtual_dom::vtag::{
2022
InputFields, TextareaFields, VTagInner, Value, MATHML_NAMESPACE, SVG_NAMESPACE,
2123
};
@@ -365,14 +367,29 @@ mod feat_hydration {
365367
);
366368
let el = node.dyn_into::<Element>().expect("expected an element.");
367369

368-
assert_eq!(
369-
el.tag_name().to_lowercase(),
370-
tag_name,
371-
"expected element of kind {}, found {}.",
372-
tag_name,
373-
el.tag_name().to_lowercase(),
374-
);
370+
{
371+
let el_tag_name = el.tag_name();
372+
let parent_namespace = _parent.namespace_uri();
375373

374+
// In HTML namespace (or no namespace), createElement is case-insensitive
375+
// In other namespaces (SVG, MathML), createElementNS is case-sensitive
376+
let should_compare_case_insensitive = parent_namespace.is_none()
377+
|| parent_namespace.as_deref() == Some(HTML_NAMESPACE);
378+
379+
if should_compare_case_insensitive {
380+
// Case-insensitive comparison for HTML elements
381+
assert!(
382+
tag_name.eq_ignore_ascii_case(&el_tag_name),
383+
"expected element of kind {tag_name}, found {el_tag_name}.",
384+
);
385+
} else {
386+
// Case-sensitive comparison for namespaced elements (SVG, MathML)
387+
assert_eq!(
388+
el_tag_name, tag_name,
389+
"expected element of kind {tag_name}, found {el_tag_name}.",
390+
);
391+
}
392+
}
376393
// We simply register listeners and update all attributes.
377394
let attributes = attributes.apply(root, &el);
378395
let listeners = listeners.apply(root, &el);

packages/yew/src/renderer.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ thread_local! {
1818
/// Unless a panic hook is set through this function, Yew will
1919
/// overwrite any existing panic hook when an application is rendered with [Renderer].
2020
#[cfg(feature = "csr")]
21+
#[allow(clippy::incompatible_msrv)]
2122
pub fn set_custom_panic_hook(hook: Box<dyn Fn(&PanicInfo<'_>) + Sync + Send + 'static>) {
2223
std::panic::set_hook(hook);
2324
PANIC_HOOK_IS_SET.with(|hook_is_set| hook_is_set.set(true));

packages/yew/src/tests/layout_tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::html::AnyScope;
88
use crate::virtual_dom::VNode;
99
use crate::{scheduler, Component, Context, Html};
1010

11+
#[allow(dead_code)]
1112
struct Comp;
1213
impl Component for Comp {
1314
type Message = ();

packages/yew/tests/hydration.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,3 +1077,100 @@ async fn hydrate_empty() {
10771077
let result = obtain_result_by_id("output");
10781078
assert_eq!(result.as_str(), r#"<div>after</div><div>after</div>"#);
10791079
}
1080+
1081+
#[wasm_bindgen_test]
1082+
async fn hydration_with_camelcase_svg_elements() {
1083+
#[function_component]
1084+
fn SvgWithCamelCase() -> Html {
1085+
html! {
1086+
<svg width="100" height="100">
1087+
<defs>
1088+
<@{"linearGradient"} id="gradient1">
1089+
<stop offset="0%" stop-color="red" />
1090+
<stop offset="100%" stop-color="blue" />
1091+
</@>
1092+
<@{"radialGradient"} id="gradient2">
1093+
<stop offset="0%" stop-color="yellow" />
1094+
<stop offset="100%" stop-color="green" />
1095+
</@>
1096+
<@{"clipPath"} id="clip1">
1097+
<circle cx="50" cy="50" r="40" />
1098+
</@>
1099+
</defs>
1100+
<rect x="10" y="10" width="80" height="80" fill="url(#gradient1)" />
1101+
<circle cx="50" cy="50" r="30" fill="url(#gradient2)" clip-path="url(#clip1)" />
1102+
<@{"feDropShadow"} dx="2" dy="2" stdDeviation="2" />
1103+
</svg>
1104+
}
1105+
}
1106+
1107+
#[function_component]
1108+
fn App() -> Html {
1109+
let counter = use_state(|| 0);
1110+
let onclick = {
1111+
let counter = counter.clone();
1112+
Callback::from(move |_| counter.set(*counter + 1))
1113+
};
1114+
1115+
html! {
1116+
<div id="result">
1117+
<div class="counter">{"Count: "}{*counter}</div>
1118+
<button {onclick} class="increment">{"Increment"}</button>
1119+
<SvgWithCamelCase />
1120+
</div>
1121+
}
1122+
}
1123+
1124+
// Server render
1125+
let s = ServerRenderer::<App>::new().render().await;
1126+
1127+
// Set HTML
1128+
gloo::utils::document()
1129+
.query_selector("#output")
1130+
.unwrap()
1131+
.unwrap()
1132+
.set_inner_html(&s);
1133+
1134+
sleep(Duration::ZERO).await;
1135+
1136+
// Hydrate - this should not panic
1137+
Renderer::<App>::with_root(gloo::utils::document().get_element_by_id("output").unwrap())
1138+
.hydrate();
1139+
1140+
sleep(Duration::from_millis(10)).await;
1141+
1142+
// Verify the SVG elements are present and properly cased
1143+
let svg = gloo::utils::document()
1144+
.query_selector("svg")
1145+
.unwrap()
1146+
.unwrap();
1147+
1148+
let linear_gradient = svg.query_selector("linearGradient").unwrap().unwrap();
1149+
assert_eq!(linear_gradient.tag_name(), "linearGradient");
1150+
1151+
let radial_gradient = svg.query_selector("radialGradient").unwrap().unwrap();
1152+
assert_eq!(radial_gradient.tag_name(), "radialGradient");
1153+
1154+
let clip_path = svg.query_selector("clipPath").unwrap().unwrap();
1155+
assert_eq!(clip_path.tag_name(), "clipPath");
1156+
1157+
// Test interactivity still works after hydration
1158+
gloo::utils::document()
1159+
.query_selector(".increment")
1160+
.unwrap()
1161+
.unwrap()
1162+
.dyn_into::<HtmlElement>()
1163+
.unwrap()
1164+
.click();
1165+
1166+
sleep(Duration::from_millis(10)).await;
1167+
1168+
let counter_text = gloo::utils::document()
1169+
.query_selector(".counter")
1170+
.unwrap()
1171+
.unwrap()
1172+
.text_content()
1173+
.unwrap();
1174+
1175+
assert_eq!(counter_text, "Count: 1");
1176+
}

0 commit comments

Comments
 (0)