diff --git a/examples/inner_html/src/document.html b/examples/inner_html/src/document.html index dbff84fe611..8ccc6707657 100644 --- a/examples/inner_html/src/document.html +++ b/examples/inner_html/src/document.html @@ -4,10 +4,6 @@

Inline HTML with SVG

Rust source code. The code queries the DOM, creates a new element, and applies this snippet of HTML to the element's innerHTML.

-

- If you look at your browser's console you can see the DOM element (logged to - the console). -

) -> Self { - Self { value: 0 } + Self } fn view(&self, _ctx: &Context) -> Html { - let div = gloo::utils::document().create_element("div").unwrap(); - div.set_inner_html(HTML); - // See - console::log_1(&div); - - Html::VRef(div.into()) + Html::from_html_unchecked(HTML.into()) } } diff --git a/packages/yew/Cargo.toml b/packages/yew/Cargo.toml index 1d5c6f72d53..e609be9c194 100644 --- a/packages/yew/Cargo.toml +++ b/packages/yew/Cargo.toml @@ -56,6 +56,7 @@ features = [ "FocusEvent", "HtmlElement", "HtmlInputElement", + "HtmlCollection", "HtmlTextAreaElement", "InputEvent", "InputEventInit", @@ -91,6 +92,7 @@ version = "0.3" features = [ "ShadowRootInit", "ShadowRootMode", + "HtmlButtonElement" ] [features] diff --git a/packages/yew/src/dom_bundle/bnode.rs b/packages/yew/src/dom_bundle/bnode.rs index 1791729726a..616832af0a4 100644 --- a/packages/yew/src/dom_bundle/bnode.rs +++ b/packages/yew/src/dom_bundle/bnode.rs @@ -4,7 +4,7 @@ use std::fmt; use web_sys::{Element, Node}; -use super::{BComp, BList, BPortal, BSubtree, BSuspense, BTag, BText}; +use super::{BComp, BList, BPortal, BRaw, BSubtree, BSuspense, BTag, BText}; use crate::dom_bundle::{Reconcilable, ReconcileTarget}; use crate::html::{AnyScope, NodeRef}; use crate::virtual_dom::{Key, VNode}; @@ -25,6 +25,8 @@ pub(super) enum BNode { Ref(Node), /// A suspendible document fragment. Suspense(Box), + /// A raw HTML string, represented by [`AttrValue`](crate::AttrValue). + Raw(BRaw), } impl BNode { @@ -38,6 +40,7 @@ impl BNode { Self::Text(_) => None, Self::Portal(bportal) => bportal.key(), Self::Suspense(bsusp) => bsusp.key(), + Self::Raw(_) => None, } } } @@ -58,6 +61,7 @@ impl ReconcileTarget for BNode { } Self::Portal(bportal) => bportal.detach(root, parent, parent_to_detach), Self::Suspense(bsusp) => bsusp.detach(root, parent, parent_to_detach), + Self::Raw(raw) => raw.detach(root, parent, parent_to_detach), } } @@ -76,6 +80,7 @@ impl ReconcileTarget for BNode { } Self::Portal(ref vportal) => vportal.shift(next_parent, next_sibling), Self::Suspense(ref vsuspense) => vsuspense.shift(next_parent, next_sibling), + Self::Raw(ref braw) => braw.shift(next_parent, next_sibling), } } } @@ -120,6 +125,10 @@ impl Reconcilable for VNode { vsuspsense.attach(root, parent_scope, parent, next_sibling); (node_ref, suspsense.into()) } + VNode::VRaw(vraw) => { + let (node_ref, raw) = vraw.attach(root, parent_scope, parent, next_sibling); + (node_ref, raw.into()) + } } } @@ -176,6 +185,9 @@ impl Reconcilable for VNode { VNode::VSuspense(vsuspsense) => { vsuspsense.reconcile_node(root, parent_scope, parent, next_sibling, bundle) } + VNode::VRaw(vraw) => { + vraw.reconcile_node(root, parent_scope, parent, next_sibling, bundle) + } } } } @@ -222,6 +234,13 @@ impl From for BNode { } } +impl From for BNode { + #[inline] + fn from(braw: BRaw) -> Self { + Self::Raw(braw) + } +} + impl fmt::Debug for BNode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match *self { @@ -232,6 +251,7 @@ impl fmt::Debug for BNode { Self::Ref(ref vref) => write!(f, "VRef ( \"{}\" )", crate::utils::print_node(vref)), Self::Portal(ref vportal) => vportal.fmt(f), Self::Suspense(ref bsusp) => bsusp.fmt(f), + Self::Raw(ref braw) => braw.fmt(f), } } } @@ -285,6 +305,9 @@ mod feat_hydration { vsuspense.hydrate(root, parent_scope, parent, fragment); (node_ref, suspense.into()) } + VNode::VRaw(_) => { + panic!("VRaw is not hydratable (raw HTML string cannot be hydrated)") + } } } } diff --git a/packages/yew/src/dom_bundle/braw.rs b/packages/yew/src/dom_bundle/braw.rs new file mode 100644 index 00000000000..d0b3828e316 --- /dev/null +++ b/packages/yew/src/dom_bundle/braw.rs @@ -0,0 +1,400 @@ +use wasm_bindgen::JsCast; +use web_sys::Element; + +use crate::dom_bundle::bnode::BNode; +use crate::dom_bundle::traits::{Reconcilable, ReconcileTarget}; +use crate::dom_bundle::utils::insert_node; +use crate::dom_bundle::BSubtree; +use crate::html::AnyScope; +use crate::virtual_dom::VRaw; +use crate::{AttrValue, NodeRef}; + +#[derive(Debug)] +pub struct BRaw { + reference: NodeRef, + children_count: usize, + html: AttrValue, +} + +impl BRaw { + fn create_elements(html: &str) -> Vec { + let div = gloo::utils::document().create_element("div").unwrap(); + div.set_inner_html(html); + let children = div.children(); + let children = js_sys::Array::from(&children); + let children = children.to_vec(); + children + .into_iter() + .map(|it| it.unchecked_into()) + .collect::>() + } + + fn detach_bundle(&self, parent: &Element) { + let mut next_node = self.reference.get(); + for _ in 0..self.children_count { + if let Some(node) = next_node { + next_node = node.next_sibling(); + parent.remove_child(&node).unwrap(); + } + } + } +} + +impl ReconcileTarget for BRaw { + fn detach(self, _root: &BSubtree, parent: &Element, _parent_to_detach: bool) { + self.detach_bundle(parent); + } + + fn shift(&self, next_parent: &Element, next_sibling: NodeRef) -> NodeRef { + let mut next_node = match self.reference.get() { + Some(n) => n, + None => return NodeRef::default(), + }; + let insert = |n| { + next_parent + .insert_before(&n, next_sibling.get().as_ref()) + .unwrap() + }; + for _ in 0..self.children_count { + let current = next_node; + next_node = match current.next_sibling() { + Some(n) => n, + None => { + // if nothing is next, add whatever is the current node and return early + insert(current.clone()); + return NodeRef::new(current); + } + }; + insert(current); + } + NodeRef::new(next_node) + } +} + +impl Reconcilable for VRaw { + type Bundle = BRaw; + + fn attach( + self, + _root: &BSubtree, + _parent_scope: &AnyScope, + parent: &Element, + next_sibling: NodeRef, + ) -> (NodeRef, Self::Bundle) { + let elements = BRaw::create_elements(&self.html); + if elements.is_empty() { + return ( + next_sibling.clone(), + BRaw { + reference: next_sibling, + children_count: 0, + html: self.html, + }, + ); + } + let node_ref = NodeRef::default(); + + let count = elements.len(); + let mut iter = elements.into_iter(); + let first = iter.next().unwrap(); + insert_node(&first, parent, next_sibling.get().as_ref()); + node_ref.set(Some(first.into())); + for child in iter { + insert_node(&child, parent, next_sibling.get().as_ref()); + } + ( + node_ref.clone(), + BRaw { + reference: node_ref, + children_count: count, + html: self.html, + }, + ) + } + + fn reconcile_node( + self, + root: &BSubtree, + parent_scope: &AnyScope, + parent: &Element, + next_sibling: NodeRef, + bundle: &mut BNode, + ) -> NodeRef { + match bundle { + BNode::Raw(raw) if raw.html == self.html => raw.reference.clone(), + BNode::Raw(raw) => self.reconcile(root, parent_scope, parent, next_sibling, raw), + _ => self.replace(root, parent_scope, parent, next_sibling, bundle), + } + } + + fn reconcile( + self, + root: &BSubtree, + parent_scope: &AnyScope, + parent: &Element, + next_sibling: NodeRef, + bundle: &mut Self::Bundle, + ) -> NodeRef { + if self.html != bundle.html { + // we don't have a way to diff what's changed in the string so we remove the node and + // reattach it + bundle.detach_bundle(parent); + let (node_ref, braw) = self.attach(root, parent_scope, parent, next_sibling); + *bundle = braw; + node_ref + } else { + bundle.reference.clone() + } + } +} + +#[cfg(target_arch = "wasm32")] +#[cfg(test)] +mod tests { + use gloo::utils::document; + use wasm_bindgen_test::{wasm_bindgen_test as test, wasm_bindgen_test_configure}; + + use super::*; + use crate::dom_bundle::utils::{setup_parent, setup_parent_and_sibling, SIBLING_CONTENT}; + use crate::virtual_dom::VNode; + + wasm_bindgen_test_configure!(run_in_browser); + + #[test] + fn braw_works_one_node() { + let (root, scope, parent) = setup_parent(); + + const HTML: &str = "text"; + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, NodeRef::default()); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), HTML) + } + + #[test] + fn braw_works_no_node() { + let (root, scope, parent) = setup_parent(); + + const HTML: &str = ""; + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, NodeRef::default()); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), HTML) + } + + #[test] + fn braw_works_one_node_nested() { + let (root, scope, parent) = setup_parent(); + + const HTML: &str = + r#"

one link more paragraph

here
"#; + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, NodeRef::default()); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), HTML) + } + #[test] + fn braw_works_multi_top_nodes() { + let (root, scope, parent) = setup_parent(); + + const HTML: &str = r#"

paragraph

link"#; + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, NodeRef::default()); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), HTML) + } + + #[test] + fn braw_detach_works_multi_node() { + let (root, scope, parent) = setup_parent(); + + const HTML: &str = r#"

paragraph

link"#; + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, NodeRef::default()); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), HTML); + elem.detach(&root, &parent, false); + assert_eq!(parent.inner_html(), ""); + } + + #[test] + fn braw_detach_works_single_node() { + let (root, scope, parent) = setup_parent(); + + const HTML: &str = r#"

paragraph

"#; + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, NodeRef::default()); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), HTML); + elem.detach(&root, &parent, false); + assert_eq!(parent.inner_html(), ""); + } + + #[test] + fn braw_detach_works_empty() { + let (root, scope, parent) = setup_parent(); + + const HTML: &str = ""; + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, NodeRef::default()); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), HTML); + elem.detach(&root, &parent, false); + assert_eq!(parent.inner_html(), ""); + } + + #[test] + fn braw_works_one_node_sibling_attached() { + let (root, scope, parent, sibling) = setup_parent_and_sibling(); + + const HTML: &str = "text"; + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, sibling); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), format!("{}{}", HTML, SIBLING_CONTENT)); + } + + #[test] + fn braw_works_no_node_sibling_attached() { + let (root, scope, parent, sibling) = setup_parent_and_sibling(); + + const HTML: &str = ""; + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, sibling); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), format!("{}{}", HTML, SIBLING_CONTENT)); + } + + #[test] + fn braw_works_one_node_nested_sibling_attached() { + let (root, scope, parent, sibling) = setup_parent_and_sibling(); + + const HTML: &str = + r#"

one link more paragraph

here
"#; + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, sibling); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), format!("{}{}", HTML, SIBLING_CONTENT)); + } + #[test] + fn braw_works_multi_top_nodes_sibling_attached() { + let (root, scope, parent, sibling) = setup_parent_and_sibling(); + + const HTML: &str = r#"

paragraph

link"#; + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, sibling); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), format!("{}{}", HTML, SIBLING_CONTENT)); + } + + #[test] + fn braw_detach_works_multi_node_sibling_attached() { + let (root, scope, parent, sibling) = setup_parent_and_sibling(); + + const HTML: &str = r#"

paragraph

link"#; + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, sibling); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), format!("{}{}", HTML, SIBLING_CONTENT)); + elem.detach(&root, &parent, false); + assert_eq!(parent.inner_html(), format!("{}", SIBLING_CONTENT)) + } + + #[test] + fn braw_detach_works_single_node_sibling_attached() { + let (root, scope, parent, sibling) = setup_parent_and_sibling(); + + const HTML: &str = r#"

paragraph

"#; + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, sibling); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), format!("{}{}", HTML, SIBLING_CONTENT)); + elem.detach(&root, &parent, false); + assert_eq!(parent.inner_html(), format!("{}", SIBLING_CONTENT)) + } + + #[test] + fn braw_detach_works_empty_sibling_attached() { + let (root, scope, parent, sibling) = setup_parent_and_sibling(); + + const HTML: &str = ""; + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, sibling); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), format!("{}{}", HTML, SIBLING_CONTENT)); + elem.detach(&root, &parent, false); + assert_eq!(parent.inner_html(), format!("{}", SIBLING_CONTENT)) + } + + #[test] + fn braw_shift_works() { + let (root, scope, parent) = setup_parent(); + const HTML: &str = r#"

paragraph

"#; + + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, NodeRef::default()); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), HTML); + + let new_parent = document().create_element("section").unwrap(); + document().body().unwrap().append_child(&parent).unwrap(); + + elem.shift(&new_parent, NodeRef::default()); + + assert_eq!(new_parent.inner_html(), HTML); + assert_eq!(parent.inner_html(), ""); + } + + #[test] + fn braw_shift_with_sibling_works() { + let (root, scope, parent, sibling) = setup_parent_and_sibling(); + const HTML: &str = r#"

paragraph

"#; + + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, sibling); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), format!("{}{}", HTML, SIBLING_CONTENT)); + + let new_parent = document().create_element("section").unwrap(); + document().body().unwrap().append_child(&parent).unwrap(); + + let new_sibling = document().create_text_node(SIBLING_CONTENT); + new_parent.append_child(&new_sibling).unwrap(); + let new_sibling_ref = NodeRef::new(new_sibling.into()); + + elem.shift(&new_parent, new_sibling_ref); + + assert_eq!(parent.inner_html(), SIBLING_CONTENT); + + assert_eq!( + new_parent.inner_html(), + format!("{}{}", HTML, SIBLING_CONTENT) + ); + } + + #[test] + fn braw_shift_works_multi_node() { + let (root, scope, parent) = setup_parent(); + const HTML: &str = r#"

paragraph

link"#; + + let elem = VNode::from_html_unchecked(HTML.into()); + let (_, mut elem) = elem.attach(&root, &scope, &parent, NodeRef::default()); + assert_braw(&mut elem); + assert_eq!(parent.inner_html(), HTML); + + let new_parent = document().create_element("section").unwrap(); + document().body().unwrap().append_child(&parent).unwrap(); + + elem.shift(&new_parent, NodeRef::default()); + + assert_eq!(parent.inner_html(), ""); + assert_eq!(new_parent.inner_html(), HTML); + } + + fn assert_braw(node: &mut BNode) -> &mut BRaw { + if let BNode::Raw(braw) = node { + return braw; + } + panic!("should be braw"); + } +} diff --git a/packages/yew/src/dom_bundle/btag/mod.rs b/packages/yew/src/dom_bundle/btag/mod.rs index d504ea0386b..87218d6f8d4 100644 --- a/packages/yew/src/dom_bundle/btag/mod.rs +++ b/packages/yew/src/dom_bundle/btag/mod.rs @@ -385,30 +385,19 @@ mod feat_hydration { #[cfg(target_arch = "wasm32")] #[cfg(test)] mod tests { - use gloo::utils::document; use wasm_bindgen::JsCast; use wasm_bindgen_test::{wasm_bindgen_test as test, wasm_bindgen_test_configure}; use web_sys::HtmlInputElement as InputElement; use super::*; + use crate::dom_bundle::utils::setup_parent; use crate::dom_bundle::{BNode, Reconcilable, ReconcileTarget}; - use crate::html::AnyScope; use crate::virtual_dom::vtag::{HTML_NAMESPACE, SVG_NAMESPACE}; use crate::virtual_dom::{AttrValue, VNode, VTag}; use crate::{html, Html, NodeRef}; wasm_bindgen_test_configure!(run_in_browser); - fn setup_parent() -> (BSubtree, AnyScope, Element) { - let scope = AnyScope::test(); - let parent = document().create_element("div").unwrap(); - let root = BSubtree::create_root(&parent); - - document().body().unwrap().append_child(&parent).unwrap(); - - (root, scope, parent) - } - #[test] fn it_compares_tags() { let a = html! { diff --git a/packages/yew/src/dom_bundle/mod.rs b/packages/yew/src/dom_bundle/mod.rs index a8cac1c900d..9e18a1672d1 100644 --- a/packages/yew/src/dom_bundle/mod.rs +++ b/packages/yew/src/dom_bundle/mod.rs @@ -14,6 +14,7 @@ mod bcomp; mod blist; mod bnode; mod bportal; +mod braw; mod bsuspense; mod btag; mod btext; @@ -26,6 +27,7 @@ use bcomp::BComp; use blist::BList; use bnode::BNode; use bportal::BPortal; +use braw::BRaw; use bsuspense::BSuspense; use btag::{BTag, Registry}; use btext::BText; diff --git a/packages/yew/src/dom_bundle/traits.rs b/packages/yew/src/dom_bundle/traits.rs index 10712be2c04..6a1d5e5cf19 100644 --- a/packages/yew/src/dom_bundle/traits.rs +++ b/packages/yew/src/dom_bundle/traits.rs @@ -32,6 +32,8 @@ pub(super) trait Reconcilable { /// - `next_sibling`: to find where to put the node. /// /// Returns a reference to the newly inserted element. + /// The [`NodeRef`] points the first element (if there are multiple nodes created), + /// or is the passed in next_sibling if there are no element is created. fn attach( self, diff --git a/packages/yew/src/dom_bundle/utils.rs b/packages/yew/src/dom_bundle/utils.rs index 827d16f8dac..5582b2c20c8 100644 --- a/packages/yew/src/dom_bundle/utils.rs +++ b/packages/yew/src/dom_bundle/utils.rs @@ -77,3 +77,46 @@ mod feat_hydration { #[cfg(feature = "hydration")] pub(super) use feat_hydration::*; + +#[cfg(test)] +mod tests { + #![allow(dead_code)] + + use gloo::utils::document; + use web_sys::Element; + + use crate::dom_bundle::BSubtree; + use crate::html::AnyScope; + use crate::NodeRef; + + pub fn setup_parent() -> (BSubtree, AnyScope, Element) { + let scope = AnyScope::test(); + let parent = document().create_element("div").unwrap(); + let root = BSubtree::create_root(&parent); + + document().body().unwrap().append_child(&parent).unwrap(); + + (root, scope, parent) + } + + pub const SIBLING_CONTENT: &str = "END"; + + pub fn setup_parent_and_sibling() -> (BSubtree, AnyScope, Element, NodeRef) { + let scope = AnyScope::test(); + let parent = document().create_element("div").unwrap(); + let root = BSubtree::create_root(&parent); + + document().body().unwrap().append_child(&parent).unwrap(); + + let end = document().create_text_node(SIBLING_CONTENT); + parent.append_child(&end).unwrap(); + let sibling = NodeRef::new(end.into()); + + (root, scope, parent, sibling) + } +} + +#[cfg(test)] +// this is needed because clippy doesn't like the import not being used +#[allow(unused_imports)] +pub(super) use tests::*; diff --git a/packages/yew/src/virtual_dom/mod.rs b/packages/yew/src/virtual_dom/mod.rs index 4a1441c0ad1..503d6610f7f 100644 --- a/packages/yew/src/virtual_dom/mod.rs +++ b/packages/yew/src/virtual_dom/mod.rs @@ -13,6 +13,8 @@ pub mod vnode; #[doc(hidden)] pub mod vportal; #[doc(hidden)] +pub mod vraw; +#[doc(hidden)] pub mod vsuspense; #[doc(hidden)] pub mod vtag; @@ -36,6 +38,8 @@ pub use self::vnode::VNode; #[doc(inline)] pub use self::vportal::VPortal; #[doc(inline)] +pub use self::vraw::VRaw; +#[doc(inline)] pub use self::vsuspense::VSuspense; #[doc(inline)] pub use self::vtag::VTag; diff --git a/packages/yew/src/virtual_dom/vnode.rs b/packages/yew/src/virtual_dom/vnode.rs index 6fcd7039823..a03c59352cc 100644 --- a/packages/yew/src/virtual_dom/vnode.rs +++ b/packages/yew/src/virtual_dom/vnode.rs @@ -8,6 +8,8 @@ use web_sys::Node; use super::{Key, VChild, VComp, VList, VPortal, VSuspense, VTag, VText}; use crate::html::BaseComponent; +use crate::virtual_dom::VRaw; +use crate::AttrValue; /// Bind virtual element to a DOM reference. #[derive(Clone)] @@ -26,6 +28,10 @@ pub enum VNode { VRef(Node), /// A suspendible document fragment. VSuspense(VSuspense), + /// A raw HTML string, represented by [`AttrValue`](crate::AttrValue). + /// + /// Also see: [`VNode::from_html_unchecked`] + VRaw(VRaw), } impl VNode { @@ -38,6 +44,7 @@ impl VNode { VNode::VText(_) => None, VNode::VPortal(vportal) => vportal.node.key(), VNode::VSuspense(vsuspense) => vsuspense.key.as_ref(), + VNode::VRaw(_) => None, } } @@ -45,6 +52,40 @@ impl VNode { pub fn has_key(&self) -> bool { self.key().is_some() } + + /// Create a [`VNode`] from a string of HTML + /// + /// # Behavior in browser + /// + /// In the browser, this function creates an element, sets the passed HTML to its `innerHTML` + /// and inserts the contents of it into the DOM. + /// + /// # Behavior on server + /// + /// When rendering on the server, the contents of HTML are directly injected into the HTML + /// stream. + /// + /// ## Warning + /// + /// The contents are **not** sanitized or validated. You, as the developer, are responsible to + /// ensure the HTML string passed to this method are _valid_ and _not malicious_ + /// + /// # Example + /// + /// ```rust + /// use yew::{html, AttrValue, Html}; + /// # fn _main() { + /// let parsed = Html::from_html_unchecked(AttrValue::from("
content
")); + /// let _: Html = html! { + ///
+ /// {parsed} + ///
+ /// }; + /// # } + /// ``` + pub fn from_html_unchecked(html: AttrValue) -> Self { + VNode::VRaw(VRaw { html }) + } } impl Default for VNode { @@ -129,6 +170,7 @@ impl fmt::Debug for VNode { VNode::VRef(ref vref) => write!(f, "VRef ( \"{}\" )", crate::utils::print_node(vref)), VNode::VPortal(ref vportal) => vportal.fmt(f), VNode::VSuspense(ref vsuspense) => vsuspense.fmt(f), + VNode::VRaw(ref vraw) => write!(f, "VRaw {{ {} }}", vraw.html), } } } @@ -142,6 +184,7 @@ impl PartialEq for VNode { (VNode::VRef(a), VNode::VRef(b)) => a == b, // TODO: Need to improve PartialEq for VComp before enabling. (VNode::VComp(_), VNode::VComp(_)) => false, + (VNode::VRaw(a), VNode::VRaw(b)) => a.html == b.html, _ => false, } } @@ -194,6 +237,8 @@ mod feat_ssr { .render_into_stream(w, parent_scope, hydratable) .await } + + VNode::VRaw(vraw) => vraw.render_into_stream(w, parent_scope, hydratable).await, } } diff --git a/packages/yew/src/virtual_dom/vraw.rs b/packages/yew/src/virtual_dom/vraw.rs new file mode 100644 index 00000000000..9c634b7223a --- /dev/null +++ b/packages/yew/src/virtual_dom/vraw.rs @@ -0,0 +1,33 @@ +use crate::AttrValue; + +/// A raw HTML string to be used in VDOM. +#[derive(Clone, Debug)] +pub struct VRaw { + pub html: AttrValue, +} + +impl From for VRaw { + fn from(html: AttrValue) -> Self { + Self { html } + } +} + +#[cfg(feature = "ssr")] +mod feat_ssr { + use std::fmt::Write; + + use super::*; + use crate::html::AnyScope; + use crate::platform::fmt::BufWriter; + + impl VRaw { + pub(crate) async fn render_into_stream( + &self, + w: &mut BufWriter, + _parent_scope: &AnyScope, + _hydratable: bool, + ) { + let _ = w.write_str(self.html.as_ref()); + } + } +} diff --git a/packages/yew/tests/raw_html.rs b/packages/yew/tests/raw_html.rs new file mode 100644 index 00000000000..031ba7f50db --- /dev/null +++ b/packages/yew/tests/raw_html.rs @@ -0,0 +1,251 @@ +mod common; + +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsCast; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_test::wasm_bindgen_test as test; +use yew::prelude::*; +#[cfg(target_arch = "wasm32")] +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); +#[cfg(not(target_arch = "wasm32"))] +use tokio::test; + +macro_rules! create_test { + ($name:ident, $html:expr) => { + create_test!($name, $html, $html); + }; + ($name:ident, $raw:expr, $expected:expr) => { + #[test] + async fn $name() { + #[function_component] + fn App() -> Html { + let raw = Html::from_html_unchecked(AttrValue::from($raw)); + html! { +
+ {raw} +
+ } + } + + #[cfg(target_arch = "wasm32")] + { + use std::time::Duration; + + use yew::platform::time::sleep; + + yew::Renderer::::with_root( + gloo::utils::document().get_element_by_id("output").unwrap(), + ) + .render(); + + // wait for render to finish + sleep(Duration::from_millis(100)).await; + + let e = gloo::utils::document() + .get_element_by_id("raw-container") + .unwrap(); + assert_eq!(e.inner_html(), $expected); + } + #[cfg(not(target_arch = "wasm32"))] + { + let actual = yew::ServerRenderer::::new() + .hydratable(false) + .render() + .await; + assert_eq!( + actual, + format!(r#"
{}
"#, $expected) + ); + } + } + }; +} + +create_test!(empty_string, ""); +create_test!(one_node, "text"); +create_test!( + one_but_nested_node, + r#"

one link more paragraph

"# +); +create_test!( + multi_node, + r#"

paragraph

link"# +); + +macro_rules! create_update_html_test { + ($name:ident, $initial:expr, $updated:expr) => { + #[cfg(target_arch = "wasm32")] + #[test] + async fn $name() { + #[function_component] + fn App() -> Html { + let raw_html = use_state(|| ($initial)); + let onclick = { + let raw_html = raw_html.clone(); + move |_| raw_html.set($updated) + }; + let raw = Html::from_html_unchecked(AttrValue::from(*raw_html)); + html! { + <> +
+ {raw} +
+ + + } + } + use std::time::Duration; + + use yew::platform::time::sleep; + + yew::Renderer::::with_root( + gloo::utils::document().get_element_by_id("output").unwrap(), + ) + .render(); + + // wait for render to finish + sleep(Duration::from_millis(100)).await; + + let e = gloo::utils::document() + .get_element_by_id("raw-container") + .unwrap(); + assert_eq!(e.inner_html(), $initial); + + gloo::utils::document() + .get_element_by_id("click-me-btn") + .unwrap() + .unchecked_into::() + .click(); + + sleep(Duration::from_millis(100)).await; + + let e = gloo::utils::document() + .get_element_by_id("raw-container") + .unwrap(); + assert_eq!(e.inner_html(), $updated); + } + }; +} + +create_update_html_test!( + set_new_html_string, + "first", + "second" +); + +create_update_html_test!( + set_new_html_string_multiple_children, + "firstsecond", + "second" +); + +create_update_html_test!( + clear_html_string_multiple_children, + "firstsecond", + "" +); +create_update_html_test!( + nothing_changes, + "firstsecond", + "firstsecond" +); + +#[cfg(target_arch = "wasm32")] +#[test] +async fn change_vnode_types_from_other_to_vraw() { + #[function_component] + fn App() -> Html { + let node = use_state(|| html!("text")); + let onclick = { + let node = node.clone(); + move |_| { + node.set(Html::from_html_unchecked(AttrValue::from( + "second", + ))) + } + }; + html! { + <> +
+ {(*node).clone()} +
+ + + } + } + use std::time::Duration; + + use yew::platform::time::sleep; + + yew::Renderer::::with_root(gloo::utils::document().get_element_by_id("output").unwrap()) + .render(); + + // wait for render to finish + sleep(Duration::from_millis(100)).await; + + let e = gloo::utils::document() + .get_element_by_id("raw-container") + .unwrap(); + assert_eq!(e.inner_html(), "text"); + + gloo::utils::document() + .get_element_by_id("click-me-btn") + .unwrap() + .unchecked_into::() + .click(); + + sleep(Duration::from_millis(100)).await; + + let e = gloo::utils::document() + .get_element_by_id("raw-container") + .unwrap(); + assert_eq!(e.inner_html(), "second"); +} + +#[cfg(target_arch = "wasm32")] +#[test] +async fn change_vnode_types_from_vraw_to_other() { + #[function_component] + fn App() -> Html { + let node = use_state(|| Html::from_html_unchecked(AttrValue::from("second"))); + let onclick = { + let node = node.clone(); + move |_| node.set(html!("text")) + }; + html! { + <> +
+ {(*node).clone()} +
+ + + } + } + use std::time::Duration; + + use yew::platform::time::sleep; + + yew::Renderer::::with_root(gloo::utils::document().get_element_by_id("output").unwrap()) + .render(); + + // wait for render to finish + sleep(Duration::from_millis(100)).await; + + let e = gloo::utils::document() + .get_element_by_id("raw-container") + .unwrap(); + assert_eq!(e.inner_html(), "second"); + + gloo::utils::document() + .get_element_by_id("click-me-btn") + .unwrap() + .unchecked_into::() + .click(); + + sleep(Duration::from_millis(100)).await; + + let e = gloo::utils::document() + .get_element_by_id("raw-container") + .unwrap(); + assert_eq!(e.inner_html(), "text"); +}