mirror of
https://github.com/yewstack/yew.git
synced 2025-12-08 21:26:25 +00:00
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:
parent
21f373b42d
commit
93e862b1d0
@ -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">
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 = ();
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user