From 3de0414ed59e36f24518037526b67319d9981637 Mon Sep 17 00:00:00 2001 From: Greg Johnston Date: Fri, 22 Nov 2024 15:10:37 -0500 Subject: [PATCH] docs: adds more helpful text for hydration errors (closes #3267) (#3275) --- tachys/src/html/element/custom.rs | 3 +- tachys/src/html/element/elements.rs | 30 +++++-- tachys/src/html/element/mod.rs | 33 +++++-- tachys/src/hydration.rs | 129 ++++++++++++++++++++++++++-- tachys/src/mathml/mod.rs | 25 ++++-- tachys/src/svg/mod.rs | 8 +- tachys/src/view/primitives.rs | 4 +- tachys/src/view/strings.rs | 6 +- 8 files changed, 203 insertions(+), 35 deletions(-) diff --git a/tachys/src/html/element/custom.rs b/tachys/src/html/element/custom.rs index 9ed1901c73..42d618b7a6 100644 --- a/tachys/src/html/element/custom.rs +++ b/tachys/src/html/element/custom.rs @@ -9,8 +9,9 @@ where E: AsRef, { HtmlElement { + #[cfg(debug_assertions)] + defined_at: std::panic::Location::caller(), tag: Custom(tag), - attributes: (), children: (), } diff --git a/tachys/src/html/element/elements.rs b/tachys/src/html/element/elements.rs index fc804d67d7..67b5926c6f 100644 --- a/tachys/src/html/element/elements.rs +++ b/tachys/src/html/element/elements.rs @@ -25,10 +25,11 @@ macro_rules! html_element_inner { { HtmlElement { + #[cfg(debug_assertions)] + defined_at: std::panic::Location::caller(), tag: $struct_name, attributes: (), children: (), - } } @@ -55,10 +56,17 @@ macro_rules! html_element_inner { At: NextTuple, ::Output], V>>: Attribute, { - let HtmlElement { tag, children, attributes } = self; + let HtmlElement { + #[cfg(debug_assertions)] + defined_at, + tag, + children, + attributes + } = self; HtmlElement { + #[cfg(debug_assertions)] + defined_at, tag, - children, attributes: attributes.next_tuple($crate::html::attribute::$attr(value)), } @@ -118,14 +126,16 @@ macro_rules! html_self_closing_elements { paste::paste! { $( #[$meta] + #[track_caller] pub fn $tag() -> HtmlElement<[<$tag:camel>], (), ()> where { HtmlElement { + #[cfg(debug_assertions)] + defined_at: std::panic::Location::caller(), attributes: (), children: (), - tag: [<$tag:camel>], } } @@ -138,7 +148,6 @@ macro_rules! html_self_closing_elements { impl HtmlElement<[<$tag:camel>], At, ()> where At: Attribute, - { $( #[doc = concat!("The [`", stringify!($attr), "`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/", stringify!($tag), "#", stringify!($attr) ,") attribute on `<", stringify!($tag), ">`.")] @@ -151,13 +160,18 @@ macro_rules! html_self_closing_elements { V: AttributeValue, At: NextTuple, ::Output], V>>: Attribute, - { - let HtmlElement { tag, children, attributes, + let HtmlElement { + #[cfg(debug_assertions)] + defined_at, + tag, + children, + attributes, } = self; HtmlElement { + #[cfg(debug_assertions)] + defined_at, tag, - children, attributes: attributes.next_tuple($crate::html::attribute::$attr(value)), } diff --git a/tachys/src/html/element/mod.rs b/tachys/src/html/element/mod.rs index 388eb3805b..e1581ca7c8 100644 --- a/tachys/src/html/element/mod.rs +++ b/tachys/src/html/element/mod.rs @@ -1,6 +1,8 @@ +#[cfg(debug_assertions)] +use crate::hydration::set_currently_hydrating; use crate::{ html::attribute::Attribute, - hydration::Cursor, + hydration::{failed_to_cast_element, Cursor}, renderer::{CastFrom, Rndr}, ssr::StreamBuilder, view::{ @@ -24,10 +26,14 @@ pub use custom::*; pub use element_ext::*; pub use elements::*; pub use inner_html::*; +#[cfg(debug_assertions)] +use std::panic::Location; /// The typed representation of an HTML element. #[derive(Debug, PartialEq, Eq)] pub struct HtmlElement { + #[cfg(debug_assertions)] + pub(crate) defined_at: &'static Location<'static>, pub(crate) tag: E, pub(crate) attributes: At, pub(crate) children: Ch, @@ -36,8 +42,9 @@ pub struct HtmlElement { impl Clone for HtmlElement { fn clone(&self) -> Self { HtmlElement { + #[cfg(debug_assertions)] + defined_at: self.defined_at, tag: self.tag.clone(), - attributes: self.attributes.clone(), children: self.children.clone(), } @@ -75,14 +82,16 @@ where fn child(self, child: NewChild) -> Self::Output { let HtmlElement { + #[cfg(debug_assertions)] + defined_at, tag, - attributes, children, } = self; HtmlElement { + #[cfg(debug_assertions)] + defined_at, tag, - attributes, children: children.next_tuple(child.into_render()), } @@ -103,11 +112,15 @@ where attr: NewAttr, ) -> Self::Output { let HtmlElement { + #[cfg(debug_assertions)] + defined_at, tag, attributes, children, } = self; HtmlElement { + #[cfg(debug_assertions)] + defined_at, tag, attributes: attributes.add_any_attr(attr), children, @@ -229,8 +242,9 @@ where let (attributes, children) = join(self.attributes.resolve(), self.children.resolve()).await; HtmlElement { + #[cfg(debug_assertions)] + defined_at: self.defined_at, tag: self.tag, - attributes, children, } @@ -336,6 +350,11 @@ where cursor: &Cursor, position: &PositionState, ) -> Self::State { + #[cfg(debug_assertions)] + { + set_currently_hydrating(Some(self.defined_at)); + } + // non-Static custom elements need special support in templates // because they haven't been inserted type-wise if E::TAG.is_empty() && !FROM_SERVER { @@ -349,7 +368,9 @@ where cursor.sibling(); } let el = crate::renderer::types::Element::cast_from(cursor.current()) - .unwrap(); + .unwrap_or_else(|| { + failed_to_cast_element(E::TAG, cursor.current()) + }); let attrs = self.attributes.hydrate::(&el); diff --git a/tachys/src/hydration.rs b/tachys/src/hydration.rs index 5e631841ca..7f7cdd6245 100644 --- a/tachys/src/hydration.rs +++ b/tachys/src/hydration.rs @@ -2,7 +2,10 @@ use crate::{ renderer::{CastFrom, Rndr}, view::{Position, PositionState}, }; -use std::{cell::RefCell, rc::Rc}; +#[cfg(debug_assertions)] +use std::cell::Cell; +use std::{cell::RefCell, panic::Location, rc::Rc}; +use web_sys::{Comment, Element, Node, Text}; /// Hydration works by walking over the DOM, adding interactivity as needed. /// @@ -95,13 +98,121 @@ where } let marker = self.current(); position.set(Position::NextChild); - crate::renderer::types::Placeholder::cast_from(marker) - .expect("could not convert current node into marker node") - /*let marker2 = marker.clone(); - Rndr::Placeholder::cast_from(marker).unwrap_or_else(|| { - crate::dom::log("expecting to find a marker. instead, found"); - Rndr::log_node(&marker2); - panic!("oops."); - })*/ + crate::renderer::types::Placeholder::cast_from(marker.clone()) + .unwrap_or_else(|| failed_to_cast_marker_node(marker)) + } +} + +#[cfg(debug_assertions)] +thread_local! { + static CURRENTLY_HYDRATING: Cell>> = const { Cell::new(None) }; +} + +pub(crate) fn set_currently_hydrating( + location: Option<&'static Location<'static>>, +) { + #[cfg(debug_assertions)] + { + CURRENTLY_HYDRATING.set(location); + } + #[cfg(not(debug_assertions))] + { + _ = location; + } +} + +pub(crate) fn failed_to_cast_element(tag_name: &str, node: Node) -> Element { + #[cfg(not(debug_assertions))] + { + _ = node; + unreachable!(); + } + #[cfg(debug_assertions)] + { + let hydrating = CURRENTLY_HYDRATING + .take() + .map(|n| n.to_string()) + .unwrap_or_else(|| "{unknown}".to_string()); + web_sys::console::error_3( + &wasm_bindgen::JsValue::from_str(&format!( + "A hydration error occurred while trying to hydrate an \ + element defined at {hydrating}.\n\nThe framework expected an \ + HTML <{tag_name}> element, but found this instead: ", + )), + &node, + &wasm_bindgen::JsValue::from_str( + "\n\nThe hydration mismatch may have occurred slightly \ + earlier, but this is the first time the framework found a \ + node of an unexpected type.", + ), + ); + panic!( + "Unrecoverable hydration error. Please read the error message \ + directly above this for more details." + ); + } +} + +pub(crate) fn failed_to_cast_marker_node(node: Node) -> Comment { + #[cfg(not(debug_assertions))] + { + _ = node; + unreachable!(); + } + #[cfg(debug_assertions)] + { + let hydrating = CURRENTLY_HYDRATING + .take() + .map(|n| n.to_string()) + .unwrap_or_else(|| "{unknown}".to_string()); + web_sys::console::error_3( + &wasm_bindgen::JsValue::from_str(&format!( + "A hydration error occurred while trying to hydrate an \ + element defined at {hydrating}.\n\nThe framework expected a \ + marker node, but found this instead: ", + )), + &node, + &wasm_bindgen::JsValue::from_str( + "\n\nThe hydration mismatch may have occurred slightly \ + earlier, but this is the first time the framework found a \ + node of an unexpected type.", + ), + ); + panic!( + "Unrecoverable hydration error. Please read the error message \ + directly above this for more details." + ); + } +} + +pub(crate) fn failed_to_cast_text_node(node: Node) -> Text { + #[cfg(not(debug_assertions))] + { + _ = node; + unreachable!(); + } + #[cfg(debug_assertions)] + { + let hydrating = CURRENTLY_HYDRATING + .take() + .map(|n| n.to_string()) + .unwrap_or_else(|| "{unknown}".to_string()); + web_sys::console::error_3( + &wasm_bindgen::JsValue::from_str(&format!( + "A hydration error occurred while trying to hydrate an \ + element defined at {hydrating}.\n\nThe framework expected a \ + text node, but found this instead: ", + )), + &node, + &wasm_bindgen::JsValue::from_str( + "\n\nThe hydration mismatch may have occurred slightly \ + earlier, but this is the first time the framework found a \ + node of an unexpected type.", + ), + ); + panic!( + "Unrecoverable hydration error. Please read the error message \ + directly above this for more details." + ); } } diff --git a/tachys/src/mathml/mod.rs b/tachys/src/mathml/mod.rs index d7edd52a36..9d4cbc4052 100644 --- a/tachys/src/mathml/mod.rs +++ b/tachys/src/mathml/mod.rs @@ -22,10 +22,17 @@ macro_rules! mathml_global { At: NextTuple, ::Output], V>>: Attribute, { - let HtmlElement { tag, children, attributes } = self; + let HtmlElement { + #[cfg(debug_assertions)] + defined_at, + tag, + children, + attributes + } = self; HtmlElement { + #[cfg(debug_assertions)] + defined_at, tag, - children, attributes: attributes.next_tuple($crate::html::attribute::$attr(value)), } @@ -46,10 +53,11 @@ macro_rules! mathml_elements { { HtmlElement { + #[cfg(debug_assertions)] + defined_at: std::panic::Location::caller(), tag: [<$tag:camel>], attributes: (), children: (), - } } @@ -84,10 +92,17 @@ macro_rules! mathml_elements { At: NextTuple, ::Output], V>>: Attribute, { - let HtmlElement { tag, children, attributes } = self; + let HtmlElement { + #[cfg(debug_assertions)] + defined_at, + tag, + children, + attributes + } = self; HtmlElement { + #[cfg(debug_assertions)] + defined_at, tag, - children, attributes: attributes.next_tuple($crate::html::attribute::$attr(value)), } diff --git a/tachys/src/svg/mod.rs b/tachys/src/svg/mod.rs index 61cf66d909..12bfc0a79e 100644 --- a/tachys/src/svg/mod.rs +++ b/tachys/src/svg/mod.rs @@ -14,15 +14,16 @@ macro_rules! svg_elements { /// An SVG element. // `tag()` function #[allow(non_snake_case)] + #[track_caller] pub fn $tag() -> HtmlElement<[<$tag:camel>], (), ()> where - { HtmlElement { + #[cfg(debug_assertions)] + defined_at: std::panic::Location::caller(), tag: [<$tag:camel>], attributes: (), children: (), - } } @@ -153,9 +154,12 @@ svg_elements![ /// An SVG element. #[allow(non_snake_case)] +#[track_caller] pub fn r#use() -> HtmlElement where { HtmlElement { + #[cfg(debug_assertions)] + defined_at: std::panic::Location::caller(), tag: Use, attributes: (), children: (), diff --git a/tachys/src/view/primitives.rs b/tachys/src/view/primitives.rs index d32402f409..0441af882e 100644 --- a/tachys/src/view/primitives.rs +++ b/tachys/src/view/primitives.rs @@ -100,8 +100,8 @@ macro_rules! render_primitive { } let node = cursor.current(); - let node = crate::renderer::types::Text::cast_from(node) - .expect("couldn't cast text node from node"); + let node = crate::renderer::types::Text::cast_from(node.clone()) + .unwrap_or_else(|| crate::hydration::failed_to_cast_text_node(node)); if !FROM_SERVER { Rndr::set_text(&node, &self.to_string()); diff --git a/tachys/src/view/strings.rs b/tachys/src/view/strings.rs index 902e4bbf73..df4719ed10 100644 --- a/tachys/src/view/strings.rs +++ b/tachys/src/view/strings.rs @@ -90,8 +90,10 @@ impl RenderHtml for &str { } let node = cursor.current(); - let node = crate::renderer::types::Text::cast_from(node) - .expect("couldn't cast text node from node"); + let node = crate::renderer::types::Text::cast_from(node.clone()) + .unwrap_or_else(|| { + crate::hydration::failed_to_cast_text_node(node) + }); if !FROM_SERVER { Rndr::set_text(&node, self);