diff --git a/packages/core/src/diff/iterator.rs b/packages/core/src/diff/iterator.rs index 194ecec55e..852aa78220 100644 --- a/packages/core/src/diff/iterator.rs +++ b/packages/core/src/diff/iterator.rs @@ -275,7 +275,7 @@ impl VirtualDom { let old_key_to_old_index = old .iter() .enumerate() - .map(|(i, o)| (o.key.as_ref().unwrap().as_str(), i)) + .map(|(i, o)| (o.key.as_ref().unwrap(), i)) .collect::>(); let mut shared_keys = FxHashSet::default(); @@ -285,7 +285,7 @@ impl VirtualDom { .iter() .map(|node| { let key = node.key.as_ref().unwrap(); - if let Some(&index) = old_key_to_old_index.get(key.as_str()) { + if let Some(&index) = old_key_to_old_index.get(key) { shared_keys.insert(key); index } else { diff --git a/packages/core/src/hotreload_utils.rs b/packages/core/src/hotreload_utils.rs index b7b1807c89..8d793c3b60 100644 --- a/packages/core/src/hotreload_utils.rs +++ b/packages/core/src/hotreload_utils.rs @@ -6,7 +6,8 @@ use std::{ #[cfg(feature = "serialize")] use crate::nodes::deserialize_string_leaky; use crate::{ - Attribute, AttributeValue, DynamicNode, Template, TemplateAttribute, TemplateNode, VNode, VText, + Attribute, AttributeValue, DynamicNode, Key, Template, TemplateAttribute, TemplateNode, VNode, + VText, }; #[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] @@ -261,12 +262,19 @@ impl DynamicValuePool { } } - pub fn render_with(&mut self, hot_reload: &HotReloadedTemplate) -> VNode { + pub fn render_with( + &mut self, + hot_reload: &HotReloadedTemplate, + dynamic_key: Option, + ) -> VNode { // Get the node_paths from a depth first traversal of the template - let key = hot_reload - .key - .as_ref() - .map(|key| self.literal_pool.render_formatted(key)); + let key = match &hot_reload.key { + Some(HotReloadKey::Fmted(key)) => { + Some(Key::from(self.literal_pool.render_formatted(key))) + } + Some(HotReloadKey::Dynamic) => dynamic_key, + None => None, + }; let dynamic_nodes = hot_reload .dynamic_nodes .iter() @@ -345,11 +353,21 @@ pub struct TemplateGlobalKey { type StaticTemplateArray = &'static [TemplateNode]; +#[doc(hidden)] +#[derive(Debug, PartialEq, Clone)] +#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] +pub enum HotReloadKey { + /// A formatted string key whose segments can be re-rendered from the literal pool + Fmted(FmtedSegments), + /// An arbitrary hashable expression key computed at the rsx call site + Dynamic, +} + #[doc(hidden)] #[derive(Debug, PartialEq, Clone)] #[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] pub struct HotReloadedTemplate { - pub key: Option, + pub key: Option, pub dynamic_nodes: Vec, pub dynamic_attributes: Vec, pub component_values: Vec, @@ -364,7 +382,7 @@ pub struct HotReloadedTemplate { impl HotReloadedTemplate { pub fn new( - key: Option, + key: Option, dynamic_nodes: Vec, dynamic_attributes: Vec, component_values: Vec, diff --git a/packages/core/src/lib.rs b/packages/core/src/lib.rs index 2abc70dfa0..c1162125ad 100644 --- a/packages/core/src/lib.rs +++ b/packages/core/src/lib.rs @@ -36,7 +36,7 @@ pub mod internal { #[doc(hidden)] pub use crate::hotreload_utils::{ DynamicLiteralPool, DynamicValuePool, FmtSegment, FmtedSegments, HotReloadAttributeValue, - HotReloadDynamicAttribute, HotReloadDynamicNode, HotReloadLiteral, + HotReloadDynamicAttribute, HotReloadDynamicNode, HotReloadKey, HotReloadLiteral, HotReloadTemplateWithLocation, HotReloadedTemplate, HotreloadedLiteral, NamedAttribute, TemplateGlobalKey, }; @@ -95,7 +95,7 @@ pub(crate) mod innerlude { pub use crate::innerlude::{ AnyValue, AnyhowContext, Attribute, AttributeValue, Callback, CapturedError, Component, ComponentFunction, DynamicNode, Element, ElementId, ErrorBoundary, ErrorContext, Event, - EventHandler, Fragment, HasAttributes, IntoAttributeValue, IntoDynNode, LaunchConfig, + EventHandler, Fragment, HasAttributes, IntoAttributeValue, IntoDynNode, Key, LaunchConfig, ListenerCallback, MarkerWrapper, Mutation, Mutations, NoOpMutations, OptionStringFromMarker, Properties, ReactiveContext, RenderError, Result, Runtime, RuntimeGuard, ScopeId, ScopeState, SpawnIfAsync, SubscriberList, Subscribers, SuperFrom, SuperInto, SuspendedFuture, diff --git a/packages/core/src/nodes.rs b/packages/core/src/nodes.rs index 10e673545b..53ff4b2a37 100644 --- a/packages/core/src/nodes.rs +++ b/packages/core/src/nodes.rs @@ -13,7 +13,9 @@ use std::vec; use std::{ any::{Any, TypeId}, cell::Cell, + collections::hash_map::DefaultHasher, fmt::{Arguments, Debug}, + hash::{Hash, Hasher}, }; /// The information about the @@ -37,6 +39,54 @@ pub(crate) struct VNodeMount { pub(crate) mounted_dynamic_nodes: Box<[usize]>, } +/// A key that uniquely identifies a node among its siblings during keyed diffing. +/// +/// Keys are created either from a formatted string (`key: "{value}"`) or from the hash of any +/// value that implements [`Hash`] (`key: value`). +/// +/// Two keys only compare equal if they were created the same way: a key created from a string +/// never equals a key created from a hash, even if the hashed value was that same string. +/// +/// ```rust +/// use dioxus_core::Key; +/// +/// #[derive(Hash)] +/// struct UserId(u64); +/// +/// assert_eq!(Key::hashed(UserId(42)), Key::hashed(UserId(42))); +/// assert_ne!(Key::hashed(UserId(42)), Key::hashed(UserId(7))); +/// assert_eq!(Key::from("a"), Key::from("a")); +/// assert_ne!(Key::from("a"), Key::hashed("a")); +/// ``` +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Key { + /// A key rendered from a formatted string like `key: "{value}"` + Str(String), + /// A key created from the hash of an arbitrary hashable value like `key: value` + Hashed(u64), +} + +impl Key { + /// Create a key from the hash of any value that implements [`Hash`]. + pub fn hashed(value: impl Hash) -> Self { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + Self::Hashed(hasher.finish()) + } +} + +impl From for Key { + fn from(value: String) -> Self { + Self::Str(value) + } +} + +impl From<&str> for Key { + fn from(value: &str) -> Self { + Self::Str(value.to_string()) + } +} + /// A reference to a template along with any context needed to hydrate it /// /// The dynamic parts of the template are stored separately from the static parts. This allows faster diffing by skipping @@ -46,7 +96,7 @@ pub struct VNodeInner { /// The key given to the root of this template. /// /// In fragments, this is the key of the first child. In other cases, it is the key of the root. - pub key: Option, + pub key: Option, /// The static nodes and static descriptor of the template pub template: Template, @@ -152,7 +202,7 @@ impl VNode { /// Create a new VNode pub fn new( - key: Option, + key: Option, template: Template, dynamic_nodes: Box<[DynamicNode]>, dynamic_attrs: Box<[Box<[Attribute]>]>, diff --git a/packages/core/tests/diff_hashed_keys.rs b/packages/core/tests/diff_hashed_keys.rs new file mode 100644 index 0000000000..a5d9f0fd67 --- /dev/null +++ b/packages/core/tests/diff_hashed_keys.rs @@ -0,0 +1,232 @@ +//! Diffing tests for keys that are arbitrary hashable expressions rather than formatted strings. +//! +//! These mirror the scenarios in `diff_keyed_list.rs` and must produce identical mutations. + +use dioxus::dioxus_core::{ElementId, Mutation::*}; +use dioxus::prelude::*; +use dioxus_core::generation; + +/// Should result in moves, but not removals or additions +#[test] +fn hashed_keys_out_of_order() { + let mut dom = VirtualDom::new(|| { + let order = match generation() % 2 { + 0 => &[0, 1, 2, 3, /**/ 4, 5, 6, /**/ 7, 8, 9], + 1 => &[0, 1, 2, 3, /**/ 6, 4, 5, /**/ 7, 8, 9], + _ => unreachable!(), + }; + + rsx!({ + order.iter().map(|i| { + rsx! { + div { key: *i } + } + }) + }) + }); + + assert_eq!( + dom.rebuild_to_vec().edits, + [ + LoadTemplate { index: 0, id: ElementId(1,) }, + LoadTemplate { index: 0, id: ElementId(2,) }, + LoadTemplate { index: 0, id: ElementId(3,) }, + LoadTemplate { index: 0, id: ElementId(4,) }, + LoadTemplate { index: 0, id: ElementId(5,) }, + LoadTemplate { index: 0, id: ElementId(6,) }, + LoadTemplate { index: 0, id: ElementId(7,) }, + LoadTemplate { index: 0, id: ElementId(8,) }, + LoadTemplate { index: 0, id: ElementId(9,) }, + LoadTemplate { index: 0, id: ElementId(10,) }, + AppendChildren { m: 10, id: ElementId(0) }, + ] + ); + + dom.mark_dirty(ScopeId::APP); + assert_eq!( + dom.render_immediate_to_vec().edits, + [ + PushRoot { id: ElementId(7,) }, + InsertBefore { id: ElementId(5,), m: 1 }, + ] + ); +} + +/// A custom type only needs to implement `Hash` to be used as a key +#[test] +fn hashed_keys_custom_type() { + #[derive(Hash)] + struct ItemId(u64, u64); + + let mut dom = VirtualDom::new(|| { + let order: &[u64] = match generation() % 2 { + 0 => &[1, 2, 3], + 1 => &[3, 1, 2], + _ => unreachable!(), + }; + + rsx!({ + order.iter().map(|i| { + rsx! { + div { key: ItemId(*i, *i + 1) } + } + }) + }) + }); + + dom.rebuild(&mut dioxus_core::NoOpMutations); + + dom.mark_dirty(ScopeId::APP); + assert_eq!( + dom.render_immediate_to_vec().edits, + [ + PushRoot { id: ElementId(3,) }, + InsertBefore { id: ElementId(1,), m: 1 }, + ] + ); +} + +/// Should result in removals and additions, no shared keys +#[test] +fn no_common_hashed_keys() { + let mut dom = VirtualDom::new(|| { + let order: &[_] = match generation() % 2 { + 0 => &[1, 2, 3], + 1 => &[4, 5, 6], + _ => unreachable!(), + }; + + rsx!({ + order.iter().map(|i| { + rsx! { + div { key: *i } + } + }) + }) + }); + + dom.rebuild(&mut dioxus_core::NoOpMutations); + + dom.mark_dirty(ScopeId::APP); + assert_eq!( + dom.render_immediate_to_vec().edits, + [ + LoadTemplate { index: 0, id: ElementId(4) }, + LoadTemplate { index: 0, id: ElementId(5) }, + LoadTemplate { index: 0, id: ElementId(6) }, + Remove { id: ElementId(3) }, + Remove { id: ElementId(2) }, + ReplaceWith { id: ElementId(1), m: 3 } + ] + ); +} + +/// Should result in moves only +#[test] +fn hashed_keys_perfect_reverse() { + let mut dom = VirtualDom::new(|| { + let order: &[_] = match generation() % 2 { + 0 => &[1, 2, 3, 4, 5, 6, 7, 8], + 1 => &[9, 8, 7, 6, 5, 4, 3, 2, 1, 0], + _ => unreachable!(), + }; + + rsx!({ + order.iter().map(|i| { + rsx! { + div { key: *i } + } + }) + }) + }); + + dom.rebuild(&mut dioxus_core::NoOpMutations); + + dom.mark_dirty(ScopeId::APP); + assert_eq!( + dom.render_immediate_to_vec().edits, + [ + LoadTemplate { index: 0, id: ElementId(9,) }, + InsertAfter { id: ElementId(1,), m: 1 }, + LoadTemplate { index: 0, id: ElementId(10,) }, + PushRoot { id: ElementId(8,) }, + PushRoot { id: ElementId(7,) }, + PushRoot { id: ElementId(6,) }, + PushRoot { id: ElementId(5,) }, + PushRoot { id: ElementId(4,) }, + PushRoot { id: ElementId(3,) }, + PushRoot { id: ElementId(2,) }, + InsertBefore { id: ElementId(1,), m: 8 }, + ] + ) +} + +/// Components can take hashed keys too +#[test] +fn component_hashed_keys() { + let mut dom = VirtualDom::new(|| { + let g = generation(); + + let order: &[_] = match g % 2 { + 0 => &[0, 1], + 1 => &[1, 0], + _ => unreachable!(), + }; + + rsx!({ + order.iter().map(|id| { + rsx! { + iter_view { key: *id, id: *id } + } + }) + }) + }); + + #[component] + fn iter_view(id: i32) -> Element { + let text = if id == 0i32 { Some("hey") } else { None }; + rsx! { + {text} + } + } + + assert_eq!( + dom.rebuild_to_vec().edits, + [ + CreateTextNode { value: "hey".to_string(), id: ElementId(1,) }, + CreatePlaceholder { id: ElementId(2,) }, + AppendChildren { id: ElementId(0,), m: 2 } + ] + ); + + dom.mark_dirty(ScopeId::APP); + assert_eq!( + dom.render_immediate_to_vec().edits, + [ + PushRoot { id: ElementId(2,) }, + InsertBefore { id: ElementId(1,), m: 1 } + ] + ); +} + +/// Hashed keys derived from references and owned values of the same data compare equal +#[test] +fn hashed_keys_stable_across_renders() { + let mut dom = VirtualDom::new(|| { + let strings = ["a".to_string(), "b".to_string(), "c".to_string()]; + + rsx!({ + strings.iter().map(|s| { + rsx! { + div { key: s } + } + }) + }) + }); + + dom.rebuild(&mut dioxus_core::NoOpMutations); + + // Rendering the same data again should produce no edits at all + dom.mark_dirty(ScopeId::APP); + assert_eq!(dom.render_immediate_to_vec().edits, []); +} diff --git a/packages/rsx-hotreload/src/diff.rs b/packages/rsx-hotreload/src/diff.rs index 768d218642..614690d2c3 100644 --- a/packages/rsx-hotreload/src/diff.rs +++ b/packages/rsx-hotreload/src/diff.rs @@ -65,7 +65,7 @@ use dioxus_core::internal::{ FmtedSegments, HotReloadAttributeValue, HotReloadDynamicAttribute, HotReloadDynamicNode, - HotReloadLiteral, HotReloadedTemplate, NamedAttribute, + HotReloadKey, HotReloadLiteral, HotReloadedTemplate, NamedAttribute, }; use dioxus_core_types::HotReloadingContext; use dioxus_rsx::*; @@ -251,14 +251,19 @@ impl HotReloadResult { Some(()) } - fn hot_reload_key(&mut self, new: &TemplateBody) -> Option> { + fn hot_reload_key(&mut self, new: &TemplateBody) -> Option> { match new.implicit_key() { - Some(AttributeValue::AttrLiteral(HotLiteral::Fmted(value))) => Some(Some( - self.full_rebuild_state - .hot_reload_formatted_segments(value)?, - )), + Some(AttributeValue::AttrLiteral(HotLiteral::Fmted(value))) => { + Some(Some(HotReloadKey::Fmted( + self.full_rebuild_state + .hot_reload_formatted_segments(value)?, + ))) + } + // Arbitrary expression keys are computed at the rsx call site, so they can only be + // reused if the expression is unchanged from the last build + Some(other) => (self.full_rebuild_state.key.as_ref() == Some(other)) + .then_some(Some(HotReloadKey::Dynamic)), None => Some(None), - _ => None, } } diff --git a/packages/rsx-hotreload/src/last_build_state.rs b/packages/rsx-hotreload/src/last_build_state.rs index 49c2e46695..5d05ad4808 100644 --- a/packages/rsx-hotreload/src/last_build_state.rs +++ b/packages/rsx-hotreload/src/last_build_state.rs @@ -84,6 +84,8 @@ pub(crate) struct LastBuildState { /// In the new build, we must assign each of these a value even if we no longer use the component. /// The type must be the same as the last time we compiled the property pub component_properties: Vec, + /// The key of the template in the last build + pub key: Option, /// The root indexes of the last build pub root_index: DynIdx, /// The name of the original template @@ -102,6 +104,7 @@ impl LastBuildState { dynamic_nodes: BakedPool::new(dynamic_nodes), dynamic_attributes: BakedPool::new(dynamic_attributes), component_properties, + key: body.implicit_key().cloned(), root_index: body.template_idx.clone(), name, } diff --git a/packages/rsx-hotreload/tests/hotreload_pattern.rs b/packages/rsx-hotreload/tests/hotreload_pattern.rs index da87a5c1bb..808a73e0ca 100644 --- a/packages/rsx-hotreload/tests/hotreload_pattern.rs +++ b/packages/rsx-hotreload/tests/hotreload_pattern.rs @@ -6,7 +6,7 @@ use dioxus_core::{ Template, TemplateAttribute, TemplateNode, VNode, internal::{ FmtSegment, FmtedSegments, HotReloadAttributeValue, HotReloadDynamicAttribute, - HotReloadDynamicNode, HotReloadLiteral, HotReloadedTemplate, NamedAttribute, + HotReloadDynamicNode, HotReloadKey, HotReloadLiteral, HotReloadedTemplate, NamedAttribute, }, }; use dioxus_core_types::HotReloadingContext; @@ -365,13 +365,83 @@ fn valid_keys() { assert_eq!( template.key, - Some(FmtedSegments::new(vec![ + Some(HotReloadKey::Fmted(FmtedSegments::new(vec![ FmtSegment::Dynamic { id: 0 }, FmtSegment::Literal { value: "-1234" } - ])) + ]))) ); } +#[test] +fn expr_keys() { + // An unchanged expression key is reused as a dynamic key + let a = quote! { + div { + key: value, + class: "hello" + } + }; + let b = quote! { + div { + key: value, + class: "world" + } + }; + let hot_reload = hot_reload_from_tokens(a, b).unwrap(); + let template = &hot_reload[&0]; + assert_eq!(template.key, Some(HotReloadKey::Dynamic)); + + // A changed expression key requires a full rebuild + let a = quote! { + div { + key: value, + } + }; + let b = quote! { + div { + key: other_value, + } + }; + assert!(!can_hotreload(a, b)); + + // Switching from a formatted string key to an expression key requires a full rebuild + let a = quote! { + div { + key: "{value}", + } + }; + let b = quote! { + div { + key: value, + } + }; + assert!(!can_hotreload(a, b)); + + // Adding an expression key requires a full rebuild + let a = quote! { + div {} + }; + let b = quote! { + div { + key: value, + } + }; + assert!(!can_hotreload(a, b)); + + // Removing an expression key is allowed + let a = quote! { + div { + key: value, + } + }; + let b = quote! { + div {} + }; + let hot_reload = hot_reload_from_tokens(a, b).unwrap(); + let template = &hot_reload[&0]; + assert_eq!(template.key, None); +} + #[test] fn invalid_cases() { let new_invalid = quote! { @@ -1188,11 +1258,11 @@ fn component_modify_key() { let template = hot_reload.get(&0).unwrap(); assert_eq!( template.key, - Some(FmtedSegments::new(vec![ + Some(HotReloadKey::Fmted(FmtedSegments::new(vec![ FmtSegment::Dynamic { id: 0 }, FmtSegment::Literal { value: "-" }, FmtSegment::Dynamic { id: 2 }, - ])) + ]))) ); assert_eq!( template.component_values, diff --git a/packages/rsx/src/template_body.rs b/packages/rsx/src/template_body.rs index af08633f22..337a015bbb 100644 --- a/packages/rsx/src/template_body.rs +++ b/packages/rsx/src/template_body.rs @@ -104,9 +104,20 @@ impl ToTokens for TemplateBody { let node = self.normalized(); // If we have an implicit key, then we need to write its tokens - let key_tokens = match node.implicit_key() { - Some(tok) => quote! { Some( #tok.to_string() ) }, - None => quote! { None }, + // + // Formatted string keys are stored as strings so hot reloading can rewrite them from the + // literal pool. In debug mode they are rendered from the pool, so the call site passes None. + // Any other expression is hashed into the key at the call site in both modes. + let (release_key, debug_key) = match node.implicit_key() { + Some(tok @ AttributeValue::AttrLiteral(HotLiteral::Fmted(_))) => ( + quote! { Some( dioxus_core::Key::from(#tok.to_string()) ) }, + quote! { None }, + ), + Some(tok) => { + let key = quote! { Some( dioxus_core::Key::hashed(&(#tok)) ) }; + (key.clone(), key) + } + None => (quote! { None }, quote! { None }), }; let key_warnings = self.check_for_duplicate_keys(); @@ -187,7 +198,9 @@ impl ToTokens for TemplateBody { // The key needs to be created before the dynamic nodes as it might depend on a borrowed value which gets moved into the dynamic nodes #[cfg(not(debug_assertions))] - let __key = #key_tokens; + let __key = #release_key; + #[cfg(debug_assertions)] + let __key: Option = #debug_key; // These items are used in both the debug and release expansions of rsx. Pulling them out makes the expansion // slightly smaller and easier to understand. Rust analyzer also doesn't autocomplete well when it sees an ident show up twice in the expansion let __dynamic_nodes: [dioxus_core::DynamicNode; #dynamic_nodes_len] = [ #( #dynamic_nodes ),* ]; @@ -202,7 +215,7 @@ impl ToTokens for TemplateBody { Vec::from(__dynamic_attributes), __dynamic_literal_pool ); - __dynamic_value_pool.render_with(__template_read) + __dynamic_value_pool.render_with(__template_read, __key) } #[cfg(not(debug_assertions))] { @@ -282,7 +295,8 @@ impl TemplateBody { /// Ensure only one key and that the key is not a static str /// - /// todo: we want to allow arbitrary exprs for keys provided they impl hash / eq + /// Keys can be either formatted strings like `key: "{value}"` or arbitrary expressions that + /// implement `Hash` like `key: value` fn validate_key(&mut self) { let key = self.implicit_key(); @@ -290,14 +304,15 @@ impl TemplateBody { let diagnostic = match &attr { AttributeValue::AttrLiteral(ifmt) => { if ifmt.is_static() { - ifmt.span().error("Key must not be a static string. Make sure to use a formatted string like `key: \"{value}\"") + ifmt.span().error("Key must not be a static string. Make sure to use a formatted string like `key: \"{value}\"` or an expression that implements `Hash` like `key: value`") } else { return; } } + AttributeValue::AttrExpr(_) | AttributeValue::Shorthand(_) => return, _ => attr .span() - .error("Key must be in the form of a formatted string like `key: \"{value}\""), + .error("Key must be a formatted string like `key: \"{value}\"` or an expression that implements `Hash` like `key: value`"), }; self.diagnostics.push(diagnostic); @@ -381,12 +396,12 @@ impl TemplateBody { } fn hot_reload_mapping(&self) -> TokenStream2 { - let key = if let Some(AttributeValue::AttrLiteral(HotLiteral::Fmted(key))) = - self.implicit_key() - { - quote! { Some(#key) } - } else { - quote! { None } + let key = match self.implicit_key() { + Some(AttributeValue::AttrLiteral(HotLiteral::Fmted(key))) => { + quote! { Some(dioxus_core::internal::HotReloadKey::Fmted(#key)) } + } + Some(_) => quote! { Some(dioxus_core::internal::HotReloadKey::Dynamic) }, + None => quote! { None }, }; let dynamic_nodes = self.dynamic_nodes().map(|node| { let id = node.get_dyn_idx(); diff --git a/packages/rsx/tests/parsing.rs b/packages/rsx/tests/parsing.rs index dd9d25d294..81528b60fd 100644 --- a/packages/rsx/tests/parsing.rs +++ b/packages/rsx/tests/parsing.rs @@ -155,13 +155,36 @@ fn complex_kitchen_sink() { } #[test] -fn key_must_be_formatted() { +fn key_can_be_expr() { let item = quote::quote! { div { key: value } }; + let parsed = syn::parse2::(item).unwrap(); + println!("{:?}", parsed.body.diagnostics); + assert!(parsed.body.diagnostics.is_empty()); + + let item = quote::quote! { + div { + key: value.id() + } + }; + + let parsed = syn::parse2::(item).unwrap(); + println!("{:?}", parsed.body.diagnostics); + assert!(parsed.body.diagnostics.is_empty()); +} + +#[test] +fn key_cannot_be_event_handler() { + let item = quote::quote! { + div { + key: |e| {} + } + }; + let parsed = syn::parse2::(item).unwrap(); println!("{:?}", parsed.body.diagnostics); assert!(!parsed.body.diagnostics.is_empty());