diff --git a/crates/core/src/diff/fragment/error.rs b/crates/core/src/diff/fragment/error.rs index f67c93da..8035e28d 100644 --- a/crates/core/src/diff/fragment/error.rs +++ b/crates/core/src/diff/fragment/error.rs @@ -16,6 +16,10 @@ pub enum MergeError { AddChildToExisting, #[error("There was a id mismatch when merging a stream")] StreamIDMismatch, + #[error("Keyed item at position {0} not found during merge")] + KeyedItemNotFound(i32), + #[error("Keyed item at position {0} was not resolved")] + KeyedItemNotResolved(i32), #[error("Stream Error {error}")] Stream { #[from] @@ -42,6 +46,8 @@ pub enum RenderError { ChildNotFoundForStatic(i32), #[error("Cousin not found for {0}")] CousinNotFound(i32), + #[error("Keyed item at index {0} was not resolved")] + KeyedItemNotResolved(i32), #[error("Serde Error {0}")] SerdeError(Arc), #[error("Parse Error {0}")] diff --git a/crates/core/src/diff/fragment/merge.rs b/crates/core/src/diff/fragment/merge.rs index 2bee3b54..ca015309 100644 --- a/crates/core/src/diff/fragment/merge.rs +++ b/crates/core/src/diff/fragment/merge.rs @@ -75,6 +75,7 @@ impl Root { }; out.resolve_components(old_components)?; + out.fragment.expand_statics(); Ok(out) } @@ -171,19 +172,37 @@ impl Child { *statics = new_statics; } } + ( + Fragment::KeyedComprehension { statics, .. }, + Fragment::KeyedComprehension { + statics: new_statics, + .. + }, + ) => { + if statics.is_none() { + *statics = new_statics; + } + } ( Fragment::Regular { - statics, children, .. + statics, + children, + templates, + .. }, Fragment::Regular { statics: new_statics, children: new_children, + templates: new_templates, .. }, ) => { if statics.is_none() { *statics = new_statics; } + if templates.is_none() { + *templates = new_templates; + } for (id, new_child) in new_children { match children.get_mut(&id) { @@ -204,7 +223,8 @@ impl FragmentDiff { fn should_replace_current(&self) -> bool { match self { FragmentDiff::UpdateRegular { statics, .. } - | FragmentDiff::UpdateComprehension { statics, .. } => statics.is_some(), + | FragmentDiff::UpdateComprehension { statics, .. } + | FragmentDiff::UpdateKeyedComprehension { statics, .. } => statics.is_some(), } } } @@ -257,11 +277,13 @@ impl FragmentMerge for Fragment { Fragment::Regular { children: current_children, statics: current_statics, + templates: current_templates, is_root: current_reply, .. }, FragmentDiff::UpdateRegular { children: children_diffs, + templates: new_templates, is_root: new_reply, .. }, @@ -269,10 +291,12 @@ impl FragmentMerge for Fragment { let new_children = current_children.merge(children_diffs)?; let new_reply = new_reply.or(current_reply); let new_render = new_reply.map(|i| i != 0); + let templates = current_templates.merge(new_templates)?; Ok(Self::Regular { children: new_children, statics: current_statics, + templates, is_root: new_reply, new_render, }) @@ -377,6 +401,93 @@ impl FragmentMerge for Fragment { new_render, }) } + ( + Fragment::KeyedComprehension { + keyed: current_keyed, + statics, + templates: current_templates, + is_root: current_reply, + .. + }, + FragmentDiff::UpdateKeyedComprehension { + keyed: keyed_diff, + templates: new_templates, + is_root: new_reply, + .. + }, + ) => { + let new_reply = new_reply.or(current_reply); + let templates = current_templates.merge(new_templates)?; + + // Start with existing items, then apply diffs + let mut new_items = current_keyed.items.clone(); + for (key, item_diff) in keyed_diff.items { + let new_item = match item_diff { + KeyedItemDiff::MovedFrom(old_pos) => { + // Copy item from old position unchanged + let old_key = old_pos.to_string(); + current_keyed + .items + .get(&old_key) + .cloned() + .ok_or(MergeError::KeyedItemNotFound(old_pos))? + } + KeyedItemDiff::MovedWithDiff(old_pos, fragment_diff) => { + // Copy item from old position and apply diff + let old_key = old_pos.to_string(); + let old_item = current_keyed + .items + .get(&old_key) + .ok_or(MergeError::KeyedItemNotFound(old_pos))?; + match old_item { + KeyedItem::Fragment(old_fragment) => { + let merged = (*old_fragment).clone().merge(*fragment_diff)?; + KeyedItem::Fragment(Box::new(merged)) + } + KeyedItem::MovedFrom(_) | KeyedItem::MovedWithDiff(_, _) => { + // Shouldn't happen in a properly merged state + return Err(MergeError::KeyedItemNotResolved(old_pos)); + } + } + } + KeyedItemDiff::FragmentDiff(fragment_diff) => { + // Check if item already exists - if so, merge; otherwise create new + if let Some(existing_item) = current_keyed.items.get(&key) { + match existing_item { + KeyedItem::Fragment(old_fragment) => { + let merged = (*old_fragment).clone().merge(*fragment_diff)?; + KeyedItem::Fragment(Box::new(merged)) + } + KeyedItem::MovedFrom(_) | KeyedItem::MovedWithDiff(_, _) => { + // Shouldn't happen in a properly merged state + return Err(MergeError::KeyedItemNotResolved( + key.parse().unwrap_or(-1), + )); + } + } + } else { + // New item - convert from diff + let fragment: Fragment = (*fragment_diff).try_into()?; + KeyedItem::Fragment(Box::new(fragment)) + } + } + }; + new_items.insert(key, new_item); + } + + let new_render = new_reply.map(|i| i != 0); + + Ok(Self::KeyedComprehension { + keyed: KeyedItems { + items: new_items, + key_count: keyed_diff.key_count, + }, + statics, + templates, + is_root: new_reply, + new_render, + }) + } _ => Err(MergeError::FragmentTypeMismatch), } } @@ -433,12 +544,7 @@ impl FragmentMerge for Templates { (None, None) => Ok(None), (None, Some(template)) => Ok(Some(template)), (Some(template), None) => Ok(Some(template)), - (Some(mut current), Some(new)) => { - for (key, val) in new.into_iter() { - current.insert(key, val); - } - Ok(Some(current)) - } + (Some(_current), Some(new)) => Ok(Some(new)), } } } diff --git a/crates/core/src/diff/fragment/mod.rs b/crates/core/src/diff/fragment/mod.rs index 22fe06e7..b08b9c5d 100644 --- a/crates/core/src/diff/fragment/mod.rs +++ b/crates/core/src/diff/fragment/mod.rs @@ -10,7 +10,7 @@ mod tests; pub use error::*; pub use merge::*; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde::{de::DeserializeOwned, de::Deserializer, de::MapAccess, de::Visitor, Deserialize, Serialize, ser::Serializer}; use serde_json::Value; // This is the diff coming across the wire for an update to the UI. This can be @@ -86,9 +86,24 @@ pub enum FragmentDiff { #[serde(rename = "e")] event: Option, }, + /// Keyed comprehension diff - uses "k" for keyed items + UpdateKeyedComprehension { + #[serde(rename = "k")] + keyed: KeyedItemsDiff, + #[serde(rename = "p", skip_serializing_if = "Option::is_none")] + templates: Templates, + #[serde(rename = "s", skip_serializing_if = "Option::is_none")] + statics: Option, + #[serde(rename = "r", skip_serializing_if = "Option::is_none")] + is_root: Option, + #[serde(rename = "e")] + event: Option, + }, UpdateRegular { #[serde(flatten)] children: HashMap, + #[serde(rename = "p", skip_serializing_if = "Option::is_none")] + templates: Templates, #[serde(rename = "s", skip_serializing_if = "Option::is_none")] statics: Option, #[serde(rename = "r", skip_serializing_if = "Option::is_none")] @@ -120,6 +135,20 @@ pub enum Fragment { #[serde(rename = "newRender", skip_serializing_if = "Option::is_none")] new_render: Option, }, + /// Keyed comprehension - uses "k" for keyed items instead of "d" for dynamics + /// This is new in LiveView 1.1+ and enables efficient diffing of lists with keys + KeyedComprehension { + #[serde(rename = "k")] + keyed: KeyedItems, + #[serde(rename = "s")] + statics: Option, + #[serde(rename = "r", skip_serializing_if = "Option::is_none")] + is_root: Option, + #[serde(rename = "p", skip_serializing_if = "Option::is_none")] + templates: Templates, + #[serde(rename = "newRender", skip_serializing_if = "Option::is_none")] + new_render: Option, + }, Regular { #[serde(rename = "s", skip_serializing_if = "Option::is_none")] statics: Option, @@ -127,6 +156,8 @@ pub enum Fragment { is_root: Option, #[serde(flatten)] children: HashMap, + #[serde(rename = "p", skip_serializing_if = "Option::is_none")] + templates: Templates, #[serde(rename = "newRender", skip_serializing_if = "Option::is_none")] new_render: Option, }, @@ -162,12 +193,166 @@ pub enum StreamInsert { Limit(Option), } +/// Keyed items container for keyed comprehensions. +/// The "k" key in the wire protocol contains both the keyed items and a "kc" (key count) field. +/// Items are keyed by their string index ("0", "1", etc.) and kc indicates how many items there are. +#[derive(Debug, Clone, PartialEq)] +pub struct KeyedItems { + pub items: HashMap, + pub key_count: i32, +} + +/// A keyed item can be: +/// - A full fragment (new or changed item) +/// - An integer indicating the item was moved from that old position unchanged +/// - A tuple [old_pos, diff] indicating moved with changes +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum KeyedItem { + /// Item moved from old position, apply diff to get new state + MovedWithDiff(i32, Box), + /// Item moved from old position with no changes + MovedFrom(i32), + /// New or fully replaced item + Fragment(Box), +} + +/// Diff variant for keyed items +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum KeyedItemDiff { + /// Item moved from old position, apply diff to get new state + MovedWithDiff(i32, Box), + /// Item moved from old position with no changes + MovedFrom(i32), + /// New or fully replaced item as a diff + FragmentDiff(Box), +} + +impl<'de> Deserialize<'de> for KeyedItems { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct KeyedItemsVisitor; + + impl<'de> Visitor<'de> for KeyedItemsVisitor { + type Value = KeyedItems; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a map with keyed items and a kc (key count) field") + } + + fn visit_map(self, mut map: M) -> Result + where + M: MapAccess<'de>, + { + let mut items = HashMap::new(); + let mut key_count = None; + + while let Some(key) = map.next_key::()? { + if key == "kc" { + key_count = Some(map.next_value::()?); + } else { + let value = map.next_value::()?; + items.insert(key, value); + } + } + + let key_count = key_count.unwrap_or(items.len() as i32); + + Ok(KeyedItems { items, key_count }) + } + } + + deserializer.deserialize_map(KeyedItemsVisitor) + } +} + +impl Serialize for KeyedItems { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(Some(self.items.len() + 1))?; + for (k, v) in &self.items { + map.serialize_entry(k, v)?; + } + map.serialize_entry("kc", &self.key_count)?; + map.end() + } +} + +/// Keyed items diff container - similar to KeyedItems but for diffs +#[derive(Debug, Clone, PartialEq)] +pub struct KeyedItemsDiff { + pub items: HashMap, + pub key_count: i32, +} + +impl<'de> Deserialize<'de> for KeyedItemsDiff { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct KeyedItemsDiffVisitor; + + impl<'de> Visitor<'de> for KeyedItemsDiffVisitor { + type Value = KeyedItemsDiff; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a map with keyed item diffs and a kc (key count) field") + } + + fn visit_map(self, mut map: M) -> Result + where + M: MapAccess<'de>, + { + let mut items = HashMap::new(); + let mut key_count = None; + + while let Some(key) = map.next_key::()? { + if key == "kc" { + key_count = Some(map.next_value::()?); + } else { + let value = map.next_value::()?; + items.insert(key, value); + } + } + + let key_count = key_count.unwrap_or(items.len() as i32); + + Ok(KeyedItemsDiff { items, key_count }) + } + } + + deserializer.deserialize_map(KeyedItemsDiffVisitor) + } +} + +impl Serialize for KeyedItemsDiff { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(Some(self.items.len() + 1))?; + for (k, v) in &self.items { + map.serialize_entry(k, v)?; + } + map.serialize_entry("kc", &self.key_count)?; + map.end() + } +} + impl TryFrom for Fragment { type Error = MergeError; fn try_from(value: FragmentDiff) -> Result { match value { FragmentDiff::UpdateRegular { children, + templates, statics, is_root: reply, .. @@ -182,6 +367,7 @@ impl TryFrom for Fragment { children: new_children, statics, is_root: reply, + templates, new_render: None, }) } @@ -219,6 +405,45 @@ impl TryFrom for Fragment { new_render: None, }) } + FragmentDiff::UpdateKeyedComprehension { + keyed, + templates, + statics, + is_root: reply, + .. + } => { + // Convert KeyedItemsDiff to KeyedItems + let mut items = HashMap::new(); + for (key, item_diff) in keyed.items { + let item = keyed_item_diff_to_keyed_item(item_diff)?; + items.insert(key, item); + } + + Ok(Self::KeyedComprehension { + keyed: KeyedItems { + items, + key_count: keyed.key_count, + }, + statics, + templates, + is_root: reply, + new_render: None, + }) + } + } + } +} + +/// Convert a KeyedItemDiff to a KeyedItem +fn keyed_item_diff_to_keyed_item(diff: KeyedItemDiff) -> Result { + match diff { + KeyedItemDiff::MovedFrom(pos) => Ok(KeyedItem::MovedFrom(pos)), + KeyedItemDiff::MovedWithDiff(pos, fragment_diff) => { + Ok(KeyedItem::MovedWithDiff(pos, fragment_diff)) + } + KeyedItemDiff::FragmentDiff(fragment_diff) => { + let fragment: Fragment = (*fragment_diff).try_into()?; + Ok(KeyedItem::Fragment(Box::new(fragment))) } } } @@ -271,9 +496,119 @@ impl Child { statics: Some(Statics::Statics(statics)), .. }) => Some(statics.clone()), + Self::Fragment(Fragment::KeyedComprehension { + statics: Some(Statics::Statics(statics)), + .. + }) => Some(statics.clone()), _ => None, } } + +} + +impl Fragment { + /// Resolve all TemplateRef values in-place and delete templates afterward. + /// This mirrors the side effects Phoenix JS performs during `toString()`: + /// resolve refs via `templateStatic`, then `delete rendered[TEMPLATES]`. + pub fn expand_statics(&mut self) { + self.expand_statics_with_parent(&None); + } + + /// Own templates REPLACE parent (not merge). Templates are deleted after + /// extraction. KeyedComprehension uses parent templates for own resolution + /// (falls back to own when at root level). + fn expand_statics_with_parent(&mut self, parent_templates: &Templates) { + match self { + Fragment::Regular { + statics, + templates, + children, + .. + } => { + // Own replaces parent (Phoenix toOutputBuffer) + let effective = if templates.is_some() { + templates.clone() + } else { + parent_templates.clone() + }; + + if let Some(Statics::TemplateRef(id)) = statics { + if let Some(ref tmpl) = effective { + if let Some(resolved) = tmpl.get(&id.to_string()) { + *statics = Some(Statics::Statics(resolved.clone())); + } + } + } + + *templates = None; + + for child in children.values_mut() { + if let Child::Fragment(frag) = child { + frag.expand_statics_with_parent(&effective); + } + } + } + Fragment::Comprehension { + statics, + templates, + dynamics, + .. + } => { + let effective = if templates.is_some() { + templates.clone() + } else { + parent_templates.clone() + }; + + if let Some(Statics::TemplateRef(id)) = statics { + if let Some(ref tmpl) = effective { + if let Some(resolved) = tmpl.get(&id.to_string()) { + *statics = Some(Statics::Statics(resolved.clone())); + } + } + } + + *templates = None; + + for row in dynamics.iter_mut() { + for child in row.iter_mut() { + if let Child::Fragment(frag) = child { + frag.expand_statics_with_parent(&effective); + } + } + } + } + Fragment::KeyedComprehension { + statics, + templates, + keyed, + .. + } => { + // Phoenix comprehensionToBuffer: parent priority, fall back to own + let effective = if parent_templates.is_some() { + parent_templates.clone() + } else { + templates.clone() + }; + + if let Some(Statics::TemplateRef(id)) = statics { + if let Some(ref tmpl) = effective { + if let Some(resolved) = tmpl.get(&id.to_string()) { + *statics = Some(Statics::Statics(resolved.clone())); + } + } + } + + *templates = None; + + for item in keyed.items.values_mut() { + if let KeyedItem::Fragment(frag) = item { + frag.expand_statics_with_parent(&effective); + } + } + } + } + } } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] diff --git a/crates/core/src/diff/fragment/render.rs b/crates/core/src/diff/fragment/render.rs index 35370eb6..67dd06db 100644 --- a/crates/core/src/diff/fragment/render.rs +++ b/crates/core/src/diff/fragment/render.rs @@ -22,8 +22,18 @@ impl Fragment { let mut out = String::new(); match &self { Fragment::Regular { - children, statics, .. + children, + statics, + templates, + .. } => { + // Merge parent templates with local templates + let templates: Templates = match (parent_templates, templates) { + (None, None) => None, + (None, Some(t)) => Some(t.clone()), + (Some(t), None) => Some(t), + (Some(_parent), Some(child)) => Some(child.clone()), + }; match statics { None => {} Some(Statics::String(_)) => {} @@ -37,7 +47,7 @@ impl Fragment { let val = child.render( components, cousin_statics.clone(), - parent_templates.clone(), + templates.clone(), )?; out.push_str(&val); } @@ -45,8 +55,9 @@ impl Fragment { } } Some(Statics::TemplateRef(template_id)) => { - let templates = parent_templates.ok_or(RenderError::NoTemplates)?; - let template = templates + let resolved_templates = + templates.as_ref().ok_or(RenderError::NoTemplates)?; + let template = resolved_templates .get(&(template_id.to_string())) .ok_or(RenderError::TemplateNotFound(*template_id))?; out.push_str(&template[0]); @@ -61,7 +72,7 @@ impl Fragment { let val = child.render( components, cousin_statics.clone(), - Some(templates.clone()), + templates.clone(), )?; out.push_str(&val); out.push_str(template_item); @@ -79,7 +90,7 @@ impl Fragment { (None, None) => None, (None, Some(t)) => Some(t.clone()), (Some(t), None) => Some(t), - (Some(parent), Some(child)) => Some(parent).merge(Some(child.clone()))?, + (Some(_parent), Some(child)) => Some(child.clone()), }; match (statics, cousin_statics) { (None, None) => { @@ -161,6 +172,149 @@ impl Fragment { } } } + Fragment::KeyedComprehension { + keyed, + statics, + templates, + .. + } => { + let templates: Templates = match (parent_templates, templates) { + (None, None) => None, + (None, Some(t)) => Some(t.clone()), + (Some(t), None) => Some(t), + (Some(_parent), Some(child)) => Some(child.clone()), + }; + + // Render keyed items in order (0, 1, 2, ... up to key_count) + for i in 0..keyed.key_count { + let key = i.to_string(); + if let Some(item) = keyed.items.get(&key) { + let item_fragment = match item { + KeyedItem::Fragment(frag) => frag, + KeyedItem::MovedFrom(_) | KeyedItem::MovedWithDiff(_, _) => { + // These should have been resolved during merge + return Err(RenderError::KeyedItemNotResolved(i)); + } + }; + + match (statics, cousin_statics.as_ref()) { + (None, None) => { + // Render the fragment directly + let val = + item_fragment.render(components, None, templates.clone())?; + out.push_str(&val); + } + (Some(Statics::TemplateRef(template_id)), _) => { + if let Some(ref this_template) = templates { + if let Some(template_statics) = + this_template.get(&template_id.to_string()) + { + // Render the keyed item using the template + out.push_str(&template_statics[0]); + + // Get children from the keyed item fragment + if let Fragment::Regular { children, .. } = + item_fragment.as_ref() + { + for j in 1..template_statics.len() { + let child_key = (j - 1).to_string(); + if let Some(child) = children.get(&child_key) { + let val = child.render( + components, + None, + templates.clone(), + )?; + out.push_str(&val); + } + out.push_str(&template_statics[j]); + } + } else { + // For non-Regular fragments, just render them + let val = item_fragment.render( + components, + None, + templates.clone(), + )?; + out.push_str(&val); + // Push remaining template parts + for j in 1..template_statics.len() { + out.push_str(&template_statics[j]); + } + } + } else { + return Err(RenderError::TemplateNotFound(*template_id)); + } + } else { + return Err(RenderError::NoTemplates); + } + } + (Some(Statics::Statics(statics_vec)), _) => { + // Render the keyed item using inline statics + out.push_str(&statics_vec[0]); + + if let Fragment::Regular { children, .. } = item_fragment.as_ref() + { + for j in 1..statics_vec.len() { + let child_key = (j - 1).to_string(); + if let Some(child) = children.get(&child_key) { + let val = child.render( + components, + None, + templates.clone(), + )?; + out.push_str(&val); + } + out.push_str(&statics_vec[j]); + } + } else { + let val = item_fragment.render( + components, + None, + templates.clone(), + )?; + out.push_str(&val); + for j in 1..statics_vec.len() { + out.push_str(&statics_vec[j]); + } + } + } + (Some(Statics::String(_)), _) => { + // String statics, just render + } + (None, Some(cousin_statics_vec)) => { + // Use cousin statics similar to Comprehension + out.push_str(&cousin_statics_vec[0]); + + if let Fragment::Regular { children, .. } = item_fragment.as_ref() + { + for j in 1..cousin_statics_vec.len() { + let child_key = (j - 1).to_string(); + if let Some(child) = children.get(&child_key) { + let val = child.render( + components, + None, + templates.clone(), + )?; + out.push_str(&val); + } + out.push_str(&cousin_statics_vec[j]); + } + } else { + let val = item_fragment.render( + components, + None, + templates.clone(), + )?; + out.push_str(&val); + for j in 1..cousin_statics_vec.len() { + out.push_str(&cousin_statics_vec[j]); + } + } + } + } + } + } + } } Ok(out) } diff --git a/crates/core/src/diff/fragment/tests/mod.rs b/crates/core/src/diff/fragment/tests/mod.rs index 28d0a935..ae156f43 100644 --- a/crates/core/src/diff/fragment/tests/mod.rs +++ b/crates/core/src/diff/fragment/tests/mod.rs @@ -1056,11 +1056,14 @@ fn test_replace() { children: HashMap::from([("1".into(), Child::String("a".to_owned().into()))]), statics: Statics::Statics(vec!["b".into(), "c".into()]).into(), is_root: None, + templates: None, new_render: None, + }; let diff = FragmentDiff::UpdateRegular { children: HashMap::from([("1".into(), ChildDiff::String("foo".to_owned().into()))]), + templates: None, statics: Statics::Statics(vec!["bar".into(), "baz".into()]).into(), is_root: None, event: None, @@ -1070,7 +1073,9 @@ fn test_replace() { statics: Statics::Statics(vec!["bar".into(), "baz".into()]).into(), is_root: None, children: HashMap::from([("1".into(), Child::String("foo".to_owned().into()))]), + templates: None, new_render: None, + }; assert_eq!( @@ -1088,11 +1093,14 @@ fn test_mutate() { children: HashMap::from([("1".into(), Child::String("a".to_owned().into()))]), statics: Statics::Statics(vec!["b".into(), "c".into()]).into(), is_root: None, + templates: None, new_render: None, + }; let diff = FragmentDiff::UpdateRegular { children: HashMap::from([("1".into(), ChildDiff::String("foo".to_owned().into()))]), + templates: None, statics: None, is_root: None, event: None, @@ -1102,7 +1110,9 @@ fn test_mutate() { children: HashMap::from([("1".into(), Child::String("foo".to_owned().into()))]), statics: Statics::Statics(vec!["b".into(), "c".into()]).into(), is_root: None, + templates: None, new_render: None, + }; let merge = current.merge(diff).expect("Failed to merge diff"); @@ -1119,7 +1129,9 @@ fn fragment_render_parse() { ]), statics: Statics::Statics(vec!["1".into(), "2".into(), "3".into()]).into(), is_root: None, + templates: None, new_render: None, + }, components: HashMap::from([( "1".into(), @@ -1514,6 +1526,7 @@ fn simple() { let out = out.expect("Failed to deserialize"); let expected = FragmentDiff::UpdateRegular { children: HashMap::from([(1.to_string(), ChildDiff::String("baz".to_owned().into()))]), + templates: None, statics: None, is_root: None, event: None, @@ -1633,6 +1646,7 @@ fn test_decode_simple() { ("0".into(), ChildDiff::String("foo".to_owned().into())), ("1".into(), ChildDiff::String("bar".to_owned().into())), ]), + templates: None, statics: Some(Statics::Statics(vec!["a".into(), "b".into()])), is_root: None, event: None, @@ -1749,11 +1763,13 @@ fn test_decode_component_diff() { "0".into(), ChildDiff::Fragment(FragmentDiff::UpdateRegular { children: HashMap::from([("0".into(), ChildDiff::ComponentID(1))]), + templates: None, statics: None, is_root: None, event: None, }), )]), + templates: None, statics: None, is_root: None, event: None, @@ -1807,11 +1823,13 @@ fn test_decode_root_diff() { "0".into(), ChildDiff::Fragment(FragmentDiff::UpdateRegular { children: HashMap::from([("0".into(), ChildDiff::ComponentID(1))]), + templates: None, statics: None, is_root: None, event: None, }), )]), + templates: None, statics: None, is_root: None, event: None, @@ -1912,3 +1930,1801 @@ fn test_decode_component_with_dynamics_iterated() { }"#; let _root: RootDiff = serde_json::from_str(input).expect("Failed to deserialize fragment"); } + +/// Tests for Regular fragments with templates (LiveView 1.0+ format) +/// This format uses "p" for shared templates even in Regular (non-Comprehension) fragments, +/// with "s" as an integer template reference. +#[test] +fn regular_fragment_with_templates_simple() { + // This is the format LiveView 1.0+ sends for templates even without comprehensions + // "p" contains template parts, "s" at root level references template index + let json = r#"{"0":{"s":0,"r":1},"p":{"0":["Hello, Jetpack!"],"1":["",""]},"s":1}"#; + + let root: RootDiff = serde_json::from_str(json).expect("Failed to deserialize fragment"); + let root: Root = root.try_into().expect("Failed to convert RootDiff to Root"); + let out: String = root.try_into().expect("Failed to convert Root into string"); + + // Template "1" is ["",""], so root renders empty + // Child "0" uses template "0" which is ["Hello, Jetpack!"] + assert_eq!(out, "Hello, Jetpack!"); +} + +#[test] +fn regular_fragment_with_template_ref_and_children() { + // Regular fragment with template reference and dynamic children + let json = r#"{ + "0": "World", + "p": {"0": ["Hello, ", "!"]}, + "s": 0 + }"#; + + let root: RootDiff = serde_json::from_str(json).expect("Failed to deserialize fragment"); + let root: Root = root.try_into().expect("Failed to convert RootDiff to Root"); + let out: String = root.try_into().expect("Failed to convert Root into string"); + + assert_eq!(out, "Hello, World!"); +} + +#[test] +fn regular_fragment_with_nested_template_refs() { + // Nested regular fragments with template references + let json = r#"{ + "0": { + "0": "inner", + "s": 0 + }, + "p": { + "0": ["", ""], + "1": ["", ""] + }, + "s": 1 + }"#; + + let root: RootDiff = serde_json::from_str(json).expect("Failed to deserialize fragment"); + let root: Root = root.try_into().expect("Failed to convert RootDiff to Root"); + let out: String = root.try_into().expect("Failed to convert Root into string"); + + assert_eq!(out, "inner"); +} + +#[test] +fn regular_fragment_merge_preserves_templates() { + // Initial render with templates + let initial_json = r#"{ + "0": "first", + "p": {"0": ["", ""]}, + "s": 0 + }"#; + + let root: RootDiff = serde_json::from_str(initial_json).expect("Failed to deserialize"); + let root: Root = root.try_into().expect("Failed to convert"); + + // Diff that only updates the dynamic value (no templates in diff) + let diff_json = r#"{"0": "second"}"#; + let diff: RootDiff = serde_json::from_str(diff_json).expect("Failed to deserialize diff"); + + let merged = root.merge(diff).expect("Failed to merge"); + let out: String = merged.try_into().expect("Failed to convert"); + + // Templates should be preserved from initial render + assert_eq!(out, "second"); +} + +#[test] +fn keyed_comprehension_basic() { + // Basic keyed comprehension with template reference + let json = r#"{ + "0": { + "k": { + "0": {"0": "Item 1"}, + "1": {"0": "Item 2"}, + "kc": 2 + }, + "s": 0 + }, + "p": {"0": ["", ""]}, + "s": ["", ""] + }"#; + + let root: RootDiff = serde_json::from_str(json).expect("Failed to deserialize fragment"); + let root: Root = root.try_into().expect("Failed to convert RootDiff to Root"); + let html: String = root.try_into().expect("Failed to render"); + + assert_doc_eq!(html, "Item 1Item 2"); +} + +#[test] +fn keyed_comprehension_single_item() { + // Single keyed item + let json = r#"{ + "k": {"0": {"0": "Hello"}, "kc": 1}, + "p": {"0": ["", ""]}, + "s": 0 + }"#; + + let root: RootDiff = serde_json::from_str(json).expect("Failed to deserialize fragment"); + let root: Root = root.try_into().expect("Failed to convert RootDiff to Root"); + let html: String = root.try_into().expect("Failed to render"); + + assert_eq!(html, "Hello"); +} + +#[test] +fn keyed_comprehension_multiple_dynamics() { + // Keyed item with multiple dynamic values + let json = r#"{ + "k": { + "0": {"0": "Alice", "1": "30"}, + "kc": 1 + }, + "p": {"0": ["", "Age: ", ""]}, + "s": 0 + }"#; + + let root: RootDiff = serde_json::from_str(json).expect("Failed to deserialize fragment"); + let root: Root = root.try_into().expect("Failed to convert RootDiff to Root"); + let html: String = root.try_into().expect("Failed to render"); + + assert_doc_eq!(html, "AliceAge: 30"); +} + +#[test] +fn keyed_comprehension_empty() { + // Empty keyed comprehension + let json = r#"{ + "k": {"kc": 0}, + "p": {"0": ["", ""]}, + "s": 0 + }"#; + + let root: RootDiff = serde_json::from_str(json).expect("Failed to deserialize fragment"); + let root: Root = root.try_into().expect("Failed to convert RootDiff to Root"); + let html: String = root.try_into().expect("Failed to render"); + + assert_eq!(html, ""); +} + +#[test] +fn keyed_comprehension_nested_in_regular() { + // Keyed comprehension nested inside a regular fragment + let json = r#"{ + "0": { + "0": { + "k": { + "0": {"0": "Route 1"}, + "1": {"0": "Route 2"}, + "kc": 2 + }, + "s": 0 + }, + "1": "", + "s": 1 + }, + "p": { + "0": ["", ""], + "1": ["", "", ""], + "2": ["", ""] + }, + "s": 2 + }"#; + + let root: RootDiff = serde_json::from_str(json).expect("Failed to deserialize fragment"); + let root: Root = root.try_into().expect("Failed to convert RootDiff to Root"); + let html: String = root.try_into().expect("Failed to render"); + + assert!(html.contains("Route 1")); + assert!(html.contains("Route 2")); +} + +#[test] +fn keyed_comprehension_routes_list() { + // Real-world route list case + let json = r#"{ + "0": { + "0": { + "k": { + "0": { + "0": "601", + "1": "Amsterdam Driver 1", + "2": "Preparing", + "3": "false", + "4": " phx-value-route_id=\"abc\"", + "5": "true" + }, + "kc": 1 + }, + "s": 0 + }, + "1": "", + "s": 1 + }, + "p": { + "0": ["", "", "", ""], + "1": ["", "", ""], + "2": ["", ""] + }, + "s": 2 + }"#; + + let root: RootDiff = serde_json::from_str(json).expect("Failed to deserialize fragment"); + let root: Root = root.try_into().expect("Failed to convert RootDiff to Root"); + let html: String = root.try_into().expect("Failed to render"); + + assert!(html.contains("601")); + assert!(html.contains("Amsterdam Driver 1")); + assert!(html.contains("Preparing")); +} + +#[test] +fn keyed_comprehension_with_nested_statics() { + // Keyed items with their own nested statics, wrapped in an outer fragment + let json = r#"{ + "0": { + "k": { + "0": {"0": "A", "s": ["", ""]}, + "1": {"0": "B", "s": ["", ""]}, + "kc": 2 + } + }, + "s": ["", ""] + }"#; + + let root: RootDiff = serde_json::from_str(json).expect("Failed to deserialize fragment"); + let root: Root = root.try_into().expect("Failed to convert RootDiff to Root"); + let html: String = root.try_into().expect("Failed to render"); + + assert_doc_eq!(html, "AB"); +} + +#[test] +fn keyed_comprehension_parsing() { + // Just test that we can parse a keyed comprehension correctly + let json = r#"{ + "k": { + "0": {"0": "value1"}, + "1": {"0": "value2"}, + "kc": 2 + }, + "s": 0, + "p": {"0": ["", ""]} + }"#; + + let diff: FragmentDiff = serde_json::from_str(json).expect("Failed to deserialize"); + + match diff { + FragmentDiff::UpdateKeyedComprehension { keyed, .. } => { + assert_eq!(keyed.key_count, 2); + assert_eq!(keyed.items.len(), 2); + } + _ => panic!("Expected UpdateKeyedComprehension variant"), + } +} + +#[test] +fn keyed_comprehension_as_child() { + // Keyed comprehension appearing as a child of a regular fragment + let json = r#"{ + "0": "Header", + "1": { + "k": { + "0": {"0": "Item A"}, + "1": {"0": "Item B"}, + "kc": 2 + }, + "s": 0 + }, + "p": {"0": ["
  • ", "
  • "]}, + "s": ["

    ", "

    ", "
    "] + }"#; + + let root: RootDiff = serde_json::from_str(json).expect("Failed to deserialize fragment"); + let root: Root = root.try_into().expect("Failed to convert RootDiff to Root"); + let html: String = root.try_into().expect("Failed to render"); + + assert!(html.contains("Header")); + assert!(html.contains("Item A")); + assert!(html.contains("Item B")); +} + +#[test] +fn keyed_comprehension_partial_update() { + // Test that partial diffs to keyed items preserve unchanged fields + // This simulates the toggle scenario where only one field changes + let initial_json = r#"{ + "0": { + "k": { + "0": {"0": "601", "1": "Driver 1", "2": "Ready", "3": "false"}, + "kc": 1 + }, + "s": 0 + }, + "p": {"0": ["", "", "", ""]}, + "s": ["", ""] + }"#; + + let root: RootDiff = serde_json::from_str(initial_json).expect("Failed to deserialize"); + let root: Root = root.try_into().expect("Failed to convert"); + let html: String = root.clone().try_into().expect("Failed to render"); + + assert!(html.contains("601")); + assert!(html.contains("Driver 1")); + assert!(html.contains("Ready")); + assert!(html.contains("checked=\"false\"")); + + // Apply partial diff - only field "3" changes from "false" to "true" + let diff_json = r#"{ + "0": { + "k": { + "0": {"3": "true"}, + "kc": 1 + } + } + }"#; + + let diff: RootDiff = serde_json::from_str(diff_json).expect("Failed to deserialize diff"); + let merged = root.merge(diff).expect("Failed to merge"); + let html: String = merged.try_into().expect("Failed to render"); + + // All original fields should still be present + assert!(html.contains("601"), "Route ID should be preserved"); + assert!(html.contains("Driver 1"), "Driver name should be preserved"); + assert!(html.contains("Ready"), "Status should be preserved"); + // And the changed field should be updated + assert!(html.contains("checked=\"true\""), "Checked should be updated to true"); +} + +#[test] +fn keyed_comprehension_partial_update_multiple_items() { + // Test that when we have multiple items and only one gets a diff, + // the other items are preserved completely + let initial_json = r#"{ + "k": { + "0": {"0": "Item A", "1": "off"}, + "1": {"0": "Item B", "1": "off"}, + "2": {"0": "Item C", "1": "off"}, + "kc": 3 + }, + "p": {"0": ["", ""]}, + "s": 0 + }"#; + + let root: RootDiff = serde_json::from_str(initial_json).expect("Failed to deserialize"); + let root: Root = root.try_into().expect("Failed to convert"); + let html: String = root.clone().try_into().expect("Failed to render"); + + assert!(html.contains("Item A")); + assert!(html.contains("Item B")); + assert!(html.contains("Item C")); + + // Only update item 1's toggle - items 0 and 2 should remain unchanged + let diff_json = r#"{ + "k": { + "1": {"1": "on"}, + "kc": 3 + } + }"#; + + let diff: RootDiff = serde_json::from_str(diff_json).expect("Failed to deserialize diff"); + let merged = root.merge(diff).expect("Failed to merge"); + let html: String = merged.try_into().expect("Failed to render"); + + // All items should still be present + assert!(html.contains("Item A"), "Item A should be preserved"); + assert!(html.contains("Item B"), "Item B should be preserved"); + assert!(html.contains("Item C"), "Item C should be preserved"); + + // Item B's field should be updated, but text preserved + assert!(html.contains("checked=\"on\""), "Item B should be toggled on"); +} + +#[test] +fn keyed_comprehension_sequential_toggles() { + // Test that toggling back and forth works correctly + let initial_json = r#"{ + "k": { + "0": {"0": "Route 1", "1": "false"}, + "kc": 1 + }, + "p": {"0": ["", ""]}, + "s": 0 + }"#; + + let root: RootDiff = serde_json::from_str(initial_json).expect("Failed to deserialize"); + let root: Root = root.try_into().expect("Failed to convert"); + + // First toggle: false -> true + let diff1 = r#"{"k": {"0": {"1": "true"}, "kc": 1}}"#; + let diff1: RootDiff = serde_json::from_str(diff1).expect("deserialize"); + let root = root.merge(diff1).expect("merge"); + let html: String = root.clone().try_into().expect("render"); + assert!(html.contains("Route 1"), "Name preserved after first toggle"); + assert!(html.contains("checked=\"true\""), "First toggle worked"); + + // Second toggle: true -> false + let diff2 = r#"{"k": {"0": {"1": "false"}, "kc": 1}}"#; + let diff2: RootDiff = serde_json::from_str(diff2).expect("deserialize"); + let root = root.merge(diff2).expect("merge"); + let html: String = root.clone().try_into().expect("render"); + assert!(html.contains("Route 1"), "Name preserved after second toggle"); + assert!(html.contains("checked=\"false\""), "Second toggle worked"); + + // Third toggle: false -> true again + let diff3 = r#"{"k": {"0": {"1": "true"}, "kc": 1}}"#; + let diff3: RootDiff = serde_json::from_str(diff3).expect("deserialize"); + let root = root.merge(diff3).expect("merge"); + let html: String = root.try_into().expect("render"); + assert!(html.contains("Route 1"), "Name preserved after third toggle"); + assert!(html.contains("checked=\"true\""), "Third toggle worked"); +} + +#[test] +fn keyed_comprehension_add_item_with_partial_update() { + // Test adding a new item while also updating an existing one + let initial_json = r#"{ + "k": { + "0": {"0": "Item 1", "1": "active"}, + "kc": 1 + }, + "p": {"0": ["", "", ""]}, + "s": 0 + }"#; + + let root: RootDiff = serde_json::from_str(initial_json).expect("Failed to deserialize"); + let root: Root = root.try_into().expect("Failed to convert"); + + // Add new item 1, and update item 0's status + let diff_json = r#"{ + "k": { + "0": {"1": "inactive"}, + "1": {"0": "Item 2", "1": "active"}, + "kc": 2 + } + }"#; + + let diff: RootDiff = serde_json::from_str(diff_json).expect("Failed to deserialize diff"); + let merged = root.merge(diff).expect("Failed to merge"); + let html: String = merged.try_into().expect("Failed to render"); + + // Both items should be present with correct values + assert!(html.contains("Item 1"), "Item 1 name should be preserved"); + assert!(html.contains("Item 2"), "Item 2 should be added"); + assert!(html.contains("inactive"), "Item 1 status should be updated"); + assert!(html.contains("active"), "Item 2 status should be active"); +} + +#[test] +fn keyed_comprehension_remove_item_partial_update_remaining() { + // Test removing an item while updating the remaining one + let initial_json = r#"{ + "k": { + "0": {"0": "Item A", "1": "value1"}, + "1": {"0": "Item B", "1": "value2"}, + "kc": 2 + }, + "p": {"0": ["", "", ""]}, + "s": 0 + }"#; + + let root: RootDiff = serde_json::from_str(initial_json).expect("Failed to deserialize"); + let root: Root = root.try_into().expect("Failed to convert"); + + // Remove item 1, update item 0 + let diff_json = r#"{ + "k": { + "0": {"1": "updated_value"}, + "kc": 1 + } + }"#; + + let diff: RootDiff = serde_json::from_str(diff_json).expect("Failed to deserialize diff"); + let merged = root.merge(diff).expect("Failed to merge"); + let html: String = merged.try_into().expect("Failed to render"); + + // Only item A should remain with updated value + assert!(html.contains("Item A"), "Item A name should be preserved"); + assert!(html.contains("updated_value"), "Item A data should be updated"); + // Item B should be gone (kc reduced to 1) + assert!(!html.contains("Item B"), "Item B should be removed"); +} + +#[test] +fn keyed_comprehension_deeply_nested_partial_update() { + // Test partial update in a deeply nested structure + let initial_json = r#"{ + "0": { + "0": { + "k": { + "0": {"0": "route-1", "1": "Driver A", "2": "Pending", "3": "false", "4": "5"}, + "1": {"0": "route-2", "1": "Driver B", "2": "Active", "3": "true", "4": "10"}, + "kc": 2 + }, + "s": 0 + }, + "s": 1 + }, + "p": { + "0": ["", "", "", ""], + "1": ["", ""] + }, + "s": ["", ""] + }"#; + + let root: RootDiff = serde_json::from_str(initial_json).expect("Failed to deserialize"); + let root: Root = root.try_into().expect("Failed to convert"); + let html: String = root.clone().try_into().expect("Failed to render"); + + // Verify initial state + assert!(html.contains("Driver A")); + assert!(html.contains("Driver B")); + assert!(html.contains("Pending")); + assert!(html.contains("Active")); + + // Partial update: change status and count for route-1 only + let diff_json = r#"{ + "0": { + "0": { + "k": { + "0": {"2": "Complete", "4": "0"}, + "kc": 2 + } + } + } + }"#; + + let diff: RootDiff = serde_json::from_str(diff_json).expect("Failed to deserialize diff"); + let merged = root.merge(diff).expect("Failed to merge"); + let html: String = merged.try_into().expect("Failed to render"); + + // Route 1 should be updated + assert!(html.contains("route-1"), "route-1 id preserved"); + assert!(html.contains("Driver A"), "Driver A preserved"); + assert!(html.contains("Complete"), "Status updated to Complete"); + assert!(html.contains("0"), "Count updated to 0"); + + // Route 2 should be completely unchanged + assert!(html.contains("route-2"), "route-2 id preserved"); + assert!(html.contains("Driver B"), "Driver B preserved"); + assert!(html.contains("Active"), "Active status preserved"); + assert!(html.contains("10"), "Count 10 preserved"); +} + +/// Test that toggling a conditional multiple times doesn't cause duplication. +/// This catches bugs where stale state accumulates across transitions. +#[test] +fn conditional_toggles_multiple_times_without_duplication() { + // Initial: empty list, conditional shown + let initial_json = json!({ + "0": {"d": [], "s": ["", ""]}, + "1": {"s": [""]}, + "s": ["", "", "", ""] + }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("Failed to deserialize"); + let root: Root = root.try_into().expect("Failed to convert"); + + // Toggle 1: show list, hide empty state + let diff1 = json!({"0": {"d": [["A"]]}, "1": ""}); + let diff1: RootDiff = serde_json::from_value(diff1).expect("deserialize diff1"); + let merged1 = root.merge(diff1).expect("merge diff1"); + let html1: String = merged1.clone().try_into().expect("render"); + assert!(html1.contains("A"), "Toggle 1: Should have item A. Got: {}", html1); + assert!(!html1.contains(""), "Toggle 1: Should not have Empty. Got: {}", html1); + + // Toggle 2: hide list, show empty state + let diff2 = json!({"0": {"d": []}, "1": {"s": [""]}}); + let diff2: RootDiff = serde_json::from_value(diff2).expect("deserialize diff2"); + let merged2 = merged1.merge(diff2).expect("merge diff2"); + let html2: String = merged2.clone().try_into().expect("render"); + assert!(!html2.contains("A"), "Toggle 2: Item A should be gone. Got: {}", html2); + assert!(html2.contains(""), "Toggle 2: Should have Empty. Got: {}", html2); + assert_eq!(html2.matches("").count(), 1, "Toggle 2: Should have exactly one Empty. Got: {}", html2); + + // Toggle 3: show list again with different item + let diff3 = json!({"0": {"d": [["B"]]}, "1": ""}); + let diff3: RootDiff = serde_json::from_value(diff3).expect("deserialize diff3"); + let merged3 = merged2.merge(diff3).expect("merge diff3"); + let html3: String = merged3.try_into().expect("render"); + + // Final state should have exactly one "B", no "A", no "" + assert_eq!(html3.matches("").count(), 0, "Toggle 3: Should have no Empty. Got: {}", html3); + assert_eq!(html3.matches("B").count(), 1, "Toggle 3: Should have exactly one B. Got: {}", html3); + assert_eq!(html3.matches("A").count(), 0, "Toggle 3: Should have no A. Got: {}", html3); +} + +/// Test conditional with keyed comprehension sibling. +/// This is closer to a real-world routes scenario. +#[test] +fn conditional_with_keyed_comprehension_sibling() { + // Initial: no routes, empty state shown + let initial_json = json!({ + "0": { + "0": { + "k": {"kc": 0}, // Empty keyed comprehension + "s": 0 + }, + "1": {"s": ["No routes available"]}, // Empty state shown + "s": 1 + }, + "p": { + "0": ["", ""], // Route item template + "1": ["", "", ""] // Container + }, + "s": ["", ""] + }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("Failed to deserialize"); + let root: Root = root.try_into().expect("Failed to convert"); + let html: String = root.clone().try_into().expect("Failed to render"); + + assert!(html.contains("No routes available"), "Initial: Should show empty state. Got: {}", html); + + // Diff: add a route, hide empty state + let diff_json = json!({ + "0": { + "0": { + "k": { + "0": {"0": "Route ABC"}, + "kc": 1 + } + }, + "1": "" // Hide empty state + } + }); + + let diff: RootDiff = serde_json::from_value(diff_json).expect("Failed to deserialize diff"); + let merged = root.merge(diff).expect("Failed to merge"); + let html: String = merged.clone().try_into().expect("Failed to render"); + + assert!(!html.contains("No routes available"), "After adding route: Empty state should be hidden. Got: {}", html); + assert!(html.contains("Route ABC"), "After adding route: Should show the route. Got: {}", html); + + // Diff: remove all routes, show empty state again + let diff2_json = json!({ + "0": { + "0": { + "k": {"kc": 0} // Empty the keyed comprehension + }, + "1": {"s": ["No routes available"]} // Show empty state + } + }); + + let diff2: RootDiff = serde_json::from_value(diff2_json).expect("Failed to deserialize diff2"); + let merged2 = merged.merge(diff2).expect("Failed to merge diff2"); + let html2: String = merged2.try_into().expect("Failed to render"); + + assert!(html2.contains("No routes available"), "After removing routes: Empty state should reappear. Got: {}", html2); + assert!(!html2.contains("Route ABC"), "After removing routes: Route should be gone. Got: {}", html2); + // Ensure no duplication + assert_eq!(html2.matches("No routes available").count(), 1, + "Should have exactly one empty state message. Got: {}", html2); +} + +/// Test that TemplateRef rendering handles missing children gracefully. +/// +/// When using a template reference (s: 0 instead of inline s: [...]), +/// if a child is missing (e.g., hidden by `:if` conditional becoming false), +/// the renderer should handle it like inline statics do - by just rendering +/// nothing for that slot rather than erroring with ChildNotFoundForTemplate. +/// +/// This bug manifests as "Child N for template" errors when `:if` conditions +/// toggle elements on/off. +#[test] +fn template_ref_handles_missing_children_gracefully() { + // Initial render with all children present + // Template "0" expects 2 children: ["", "", ""] + let initial_json = json!({ + "0": "visible content", + "1": "other content", + "p": {"0": ["", "", ""]}, + "s": 0 + }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("Failed to deserialize"); + let root: Root = root.try_into().expect("Failed to convert"); + let html: String = root.clone().try_into().expect("Failed to render"); + + assert!(html.contains("visible content"), "Initial: Should have visible content"); + assert!(html.contains("other content"), "Initial: Should have other content"); + + // Now apply a diff that hides child 0 (simulating :if={false}) + // Note: child 1 stays, but child 0 is gone from the children map entirely + // (This is how Phoenix sends the diff when :if changes to false) + let diff_json = json!({ + "0": "" // Child 0 hidden - becomes empty string + }); + + let diff: RootDiff = serde_json::from_value(diff_json).expect("Failed to deserialize diff"); + let merged = root.merge(diff).expect("Failed to merge"); + + // This should NOT fail with ChildNotFoundForTemplate + // Instead, it should render with child 0 as empty + let html: String = merged.try_into().expect("Should render successfully even with hidden child"); + + assert!(!html.contains("visible content"), "After diff: visible content should be hidden"); + assert!(html.contains("other content"), "After diff: other content should remain"); +} + +/// Test that when a child becomes hidden (empty string), the template +/// renders correctly without that child's content. +#[test] +fn template_ref_with_child_becoming_empty_string() { + // Template has 3 slots: static1 + child0 + static2 + child1 + static3 + let initial_json = json!({ + "0": { + "0": "row-content", + "1": "progress-bar", + "s": 0 + }, + "p": { + "0": ["", ""] + }, + "s": ["", ""] + }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("Failed to deserialize"); + let root: Root = root.try_into().expect("Failed to convert"); + let html: String = root.clone().try_into().expect("Failed to render"); + + assert!(html.contains("row-content"), "Initial: Should have row content"); + assert!(html.contains("progress-bar"), "Initial: Should have progress bar"); + + // Hide the Row by setting child 0 to empty string + let diff_json = json!({ + "0": { + "0": "" // Row becomes hidden + } + }); + + let diff: RootDiff = serde_json::from_value(diff_json).expect("Failed to deserialize diff"); + let merged = root.merge(diff).expect("Failed to merge"); + let html: String = merged.try_into().expect("Should render without error"); + + assert!(!html.contains("row-content"), "After diff: row content should be hidden"); + assert!(html.contains("progress-bar"), "After diff: progress bar should remain"); +} + +/// Test 2: Sibling Template Independence +/// Two siblings both use template 0 +/// Diff updates one sibling's template 0 +/// Other sibling should keep original template 0 +#[test] +fn phoenix_template_scoping_siblings() { + // Two siblings both using template 0 initially + let initial_json = json!({ + "0": { + "0": "Sibling A content", + "s": 0 + }, + "1": { + "0": "Sibling B content", + "s": 0 + }, + "p": { + "0": ["", ""] + }, + "s": ["", "", ""] + }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("Failed to deserialize"); + let root: Root = root.try_into().expect("Failed to convert"); + let html: String = root.clone().try_into().expect("Failed to render"); + + assert!(html.contains("Sibling A content"), "Initial: Should have Sibling A in Box. Got: {}", html); + assert!(html.contains("Sibling B content"), "Initial: Should have Sibling B in Box. Got: {}", html); + + // Sibling A gets updated with a new template structure + // But Sibling B should keep using the original template 0 + let diff_json = json!({ + "0": { + "0": "Updated A", + "s": 1 // Sibling A now uses template 1 + }, + "p": { + "1": ["", ""] // Template 1 for sibling A + } + }); + + let diff: RootDiff = serde_json::from_value(diff_json).expect("Failed to deserialize diff"); + let merged = root.merge(diff).expect("Failed to merge"); + let html: String = merged.try_into().expect("Failed to render after merge"); + + // Sibling A should use the new Strong template + assert!(html.contains("Updated A"), "After merge: Sibling A should use Strong. Got: {}", html); + + // Sibling B should STILL use the original Box template + assert!(html.contains("Sibling B content"), "After merge: Sibling B should still use original Box. Got: {}", html); +} + +/// Regression test for the route-change template overwrite crash. +/// +/// When a diff overwrites a template index (e.g. `p.0` changes from Card statics to Column +/// statics), existing keyed comprehension items that held `TemplateRef(0)` must still render +/// with the *old* (Card) statics, not the new (Column) statics. +/// +/// Without `expand_statics`, TemplateRef(0) remained unresolved in keyed comp items. +/// A later diff that replaced `p.0` with different statics caused a +/// `ChildNotFoundForTemplate` crash because the fragment's children didn't match the +/// new template's slot count. +#[test] +fn keyed_comp_items_survive_template_overwrite() { + // Step 1: Initial render — Card-like template with 3 slots in p.0 + // The keyed comp items use s:0 (TemplateRef to p.0) + // Each keyed item has children 0, 1, 2 matching the 3-slot Card template. + let initial_json = json!({ + "0": { + "k": { + "0": {"0": "Card Title", "1": "Card Body", "2": "Card Footer"}, + "1": {"0": "Card Title 2", "1": "Card Body 2", "2": "Card Footer 2"}, + "kc": 2 + }, + "s": 0 + }, + "p": { + "0": ["", "", "
    ", "
    "] + }, + "s": ["", ""] + }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("Failed to deserialize"); + let root: Root = root.try_into().expect("Failed to convert"); + let html: String = root.clone().try_into().expect("Failed to render initial"); + + // Verify initial render + assert!(html.contains(""), "Initial: should have Card. Got: {}", html); + assert!(html.contains("Card Title"), "Initial: should have Card Title. Got: {}", html); + assert!(html.contains("Card Footer 2"), "Initial: should have Card Footer 2. Got: {}", html); + + // After expand_statics, TemplateRef(0) should have been resolved to inline statics. + // Verify that by checking the fragment structure: + match &root.fragment { + Fragment::Regular { children, .. } => { + if let Some(Child::Fragment(Fragment::KeyedComprehension { statics, .. })) = + children.get("0") + { + // The statics should now be Statics::Statics (resolved), not TemplateRef + assert!( + matches!(statics, Some(Statics::Statics(_))), + "Keyed comp statics should be resolved from TemplateRef to Statics. Got: {:?}", + statics + ); + } else { + panic!("Expected child 0 to be a KeyedComprehension fragment"); + } + } + _ => panic!("Expected Regular fragment at root"), + } + + // Step 2: Merge a diff that overwrites p.0 with a Column template (only 1 slot). + // The keyed comp items are NOT updated in this diff — they should keep the old + // resolved Card statics. Without expand_statics, they'd still hold TemplateRef(0) + // which now points to the Column template, causing ChildNotFoundForTemplate. + let diff_json = json!({ + "p": { + "0": ["", ""] + } + }); + + let diff: RootDiff = serde_json::from_value(diff_json).expect("Failed to deserialize diff"); + let merged = root.merge(diff).expect("Failed to merge"); + + // This is the critical assertion: rendering should succeed, not crash + let html: String = merged.try_into().expect( + "Render after template overwrite should succeed - keyed items should use old resolved statics" + ); + + // The keyed comp items should still render with their Card statics + assert!(html.contains(""), "After overwrite: Card template should be preserved in keyed items. Got: {}", html); + assert!(html.contains("Card Title"), "After overwrite: Card Title should be preserved. Got: {}", html); + assert!(html.contains("Card Footer 2"), "After overwrite: Card Footer 2 should be preserved. Got: {}", html); +} + +/// Regression test: after a template overwrite at the root, new children within +/// keyed comp items that have their own TemplateRef must resolve against the +/// root's *current* templates, not stale inherited copies. +/// +/// Scenario: +/// 1. Initial render: ROOT has p.0 = Card template (2 slots). +/// Keyed comp uses inline statics. Each item has a child fragment with s:0. +/// 2. Diff overwrites p.0 → Strong template (1 slot), and adds a new item "2" +/// whose child fragment also has s:0. +/// 3. After merge, new item's child TemplateRef(0) should resolve to Strong, +/// NOT stale Card template. +#[test] +fn template_overwrite_preserves_existing_items_but_resolves_new_refs() { + // Step 1: Initial render + // ROOT has p.0 = Card template. Keyed comp uses inline statics. + // Each keyed item has child "0" which is a fragment with s:0 (TemplateRef to p.0). + let initial_json = json!({ + "0": { + "k": { + "0": { + "0": { + "0": "Card Title A", + "1": "Card Body A", + "s": 0 + } + }, + "1": { + "0": { + "0": "Card Title B", + "1": "Card Body B", + "s": 0 + } + }, + "kc": 2 + }, + "s": ["", ""] + }, + "p": { + "0": ["", "", ""] + }, + "s": ["", ""] + }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("Failed to deserialize"); + let root: Root = root.try_into().expect("Failed to convert"); + let html: String = root.clone().try_into().expect("Failed to render initial"); + + // Verify initial render has Card templates + assert!(html.contains(""), "Initial should have Card. Got: {}", html); + assert!(html.contains("Card Title A"), "Initial should have Card Title A. Got: {}", html); + assert!(html.contains("Card Body B"), "Initial should have Card Body B. Got: {}", html); + + // Step 2: Merge diff that: + // - Overwrites p.0 → Strong template (1 slot) + // - Keeps existing items 0, 1 (moved from old positions) + // - Adds new item "2" with a child fragment that has s:0 + let diff_json = json!({ + "0": { + "k": { + "0": 0, + "1": 1, + "2": { + "0": { + "0": "New Strong Content", + "s": 0 + } + }, + "kc": 3 + } + }, + "p": { + "0": ["", ""] + } + }); + + let diff: RootDiff = serde_json::from_value(diff_json).expect("Failed to deserialize diff"); + let merged = root.merge(diff).expect("Failed to merge"); + + // This is the critical assertion: rendering should succeed. + // The new item "2"'s child has s:0, which should resolve to Strong (1 slot). + // If expand_statics used stale templates (Card, 2 slots), the child would + // resolve against the old Card template instead of the new Strong template. + let html: String = merged.try_into().expect( + "Render after template overwrite should succeed - new item's child should use current Strong template" + ); + + // Existing items should still render with their already-resolved Card statics + // (their TemplateRefs were resolved during initial expand_statics) + assert!(html.contains(""), "After merge: existing items should still use Card. Got: {}", html); + assert!(html.contains("Card Title A"), "After merge: Card Title A should be preserved. Got: {}", html); + + // New item's child should render with Strong template (current p.0) + assert!(html.contains("New Strong Content"), "After merge: new item's child should use Strong (current template). Got: {}", html); +} + +/// Regression test: deeply nested TemplateRefs must resolve against the root's +/// *current* templates, passed down through the tree — not stale inherited copies. +/// +/// Scenario: +/// 1. ROOT has p: {"0": tmpl_X, "1": tmpl_Y}. Nested child at depth 3 has s:1 +/// 2. Merge diff updating p.1 → tmpl_Z, and adding new nested fragment with s:1 +/// 3. The new s:1 ref should resolve to tmpl_Z, not stale tmpl_Y +#[test] +fn nested_template_ref_uses_root_templates_not_stale_inherited() { + // Step 1: Initial render — ROOT has two templates, deeply nested child uses s:1 + let initial_json = json!({ + "0": { + "0": { + "0": "deep value Y", + "s": 1 + }, + "s": ["", ""] + }, + "p": { + "0": ["", ""], + "1": ["", ""] + }, + "s": ["", ""] + }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("Failed to deserialize"); + let root: Root = root.try_into().expect("Failed to convert"); + let html: String = root.clone().try_into().expect("Failed to render initial"); + + // The deeply nested s:1 should have resolved to tmpl_Y = ["", ""] + assert!(html.contains("deep value Y"), "Initial: nested child should use Text template. Got: {}", html); + + // Step 2: Merge diff that: + // - Updates p.1 → tmpl_Z = ["", ""] + // - Adds a new nested child with s:1 + let diff_json = json!({ + "0": { + "0": { + "0": "deep value Z", + "s": 1 + } + }, + "p": { + "1": ["", ""] + } + }); + + let diff: RootDiff = serde_json::from_value(diff_json).expect("Failed to deserialize diff"); + let merged = root.merge(diff).expect("Failed to merge"); + + let html: String = merged.try_into().expect( + "Render after template update should succeed" + ); + + // The nested s:1 ref should resolve to the NEW tmpl_Z = Strong, not stale tmpl_Y = Text + assert!(html.contains("deep value Z"), "After merge: nested s:1 should resolve to Strong (new template), not Text (stale). Got: {}", html); +} + +// expand_statics regression tests — all 6 fail if expand_statics is removed. +// They exercise multi-step diff sequences that mirror real Phoenix sessions. + +/// Structural test: after Root creation, TemplateRefs must be resolved +/// to Statics::Statics and templates must be None (deleted). +#[test] +fn expand_statics_resolves_refs_and_deletes_templates() { + let initial_json = json!({ + "0": { + "0": "content", + "s": 0 + }, + "p": { + "0": ["", ""] + }, + "s": ["", ""] + }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("parse"); + let root: Root = root.try_into().expect("convert"); + + match &root.fragment { + Fragment::Regular { children, templates, .. } => { + // Templates must be None — expand_statics deletes them after resolution + assert_eq!(*templates, None, "templates should be None after expand_statics"); + + // Child's statics must be resolved from TemplateRef(0) to Statics::Statics + match children.get("0") { + Some(Child::Fragment(Fragment::Regular { statics, templates: child_templates, .. })) => { + assert!( + matches!(statics, Some(Statics::Statics(v)) if *v == vec!["".to_string(), "".to_string()]), + "Child statics should be resolved to [\"\", \"\"]. Got: {:?}", + statics + ); + assert_eq!( + *child_templates, None, + "Child templates should also be None" + ); + } + other => panic!("Expected Regular fragment child, got: {:?}", other), + } + } + other => panic!("Expected Regular fragment, got: {:?}", other), + } +} + +/// A diff that sends only NEW template keys (not all) works because +/// expand_statics already resolved old refs and deleted old templates. +/// Without expand_statics: TemplateNotFound(0) on render. +#[test] +fn partial_template_diff_after_expand_statics_deletion() { + // Initial: two siblings both using template 0 + let initial_json = json!({ + "0": { + "0": "Sibling A", + "s": 0 + }, + "1": { + "0": "Sibling B", + "s": 0 + }, + "p": { + "0": ["", ""] + }, + "s": ["", "", ""] + }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("parse"); + let root: Root = root.try_into().expect("convert"); + + // Verify initial render + let html: String = root.clone().try_into().expect("render initial"); + assert!(html.contains("Sibling A"), "Initial A: {}", html); + assert!(html.contains("Sibling B"), "Initial B: {}", html); + + // Diff: sibling A switches to template 1 (Strong). + // Only template 1 is sent — template 0 is NOT included. + // This works because expand_statics already resolved sibling B's statics + // to Statics::Statics(["", ""]) and deleted all templates. + let diff_json = json!({ + "0": { + "0": "Updated A", + "s": 1 + }, + "p": { + "1": ["", ""] + } + }); + + let diff: RootDiff = serde_json::from_value(diff_json).expect("parse diff"); + let merged = root.merge(diff).expect("merge"); + let html: String = merged.try_into().expect("render after partial template diff"); + + assert!(html.contains("Updated A"), "A should use Strong: {}", html); + assert!(html.contains("Sibling B"), "B should keep Box: {}", html); +} + +/// Three-step sequence: render → content update → template change. +/// Without expand_statics, step 3 crashes (3-slot items vs 1-slot Column). +#[test] +fn three_step_sequence_dynamic_update_then_template_change() { + // Step 1: Initial render with Card template (3 slots) + let initial_json = json!({ + "0": { + "k": { + "0": {"0": "Title A", "1": "Body A", "2": "Footer A"}, + "1": {"0": "Title B", "1": "Body B", "2": "Footer B"}, + "kc": 2 + }, + "s": 0 + }, + "p": { + "0": ["", "", "
    ", "
    "] + }, + "s": ["", ""] + }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("parse"); + let root: Root = root.try_into().expect("convert"); + let html: String = root.clone().try_into().expect("render step 1"); + assert!(html.contains("Title A"), "Step 1: {}", html); + + // Step 2: Dynamic content update — no template change, just content + let diff2 = json!({ + "0": { + "k": { + "0": {"0": "Title A UPDATED", "1": "Body A", "2": "Footer A"}, + "1": 1, + "kc": 2 + } + } + }); + + let diff: RootDiff = serde_json::from_value(diff2).expect("parse diff 2"); + let merged = root.merge(diff).expect("merge step 2"); + let html: String = merged.clone().try_into().expect("render step 2"); + assert!(html.contains("Title A UPDATED"), "Step 2 updated: {}", html); + assert!(html.contains("Title B"), "Step 2 preserved: {}", html); + + // Step 3: Template change — p.0 becomes Column (1 slot). + // Existing items have 3 children each. Without expand_statics, their + // TemplateRef(0) would now resolve to Column (1 slot) → crash. + let diff3 = json!({ + "p": { + "0": ["", ""] + } + }); + + let diff: RootDiff = serde_json::from_value(diff3).expect("parse diff 3"); + let merged2 = merged.merge(diff).expect("merge step 3"); + + // Critical: this must not crash. Items keep their resolved Card statics. + let html: String = merged2.try_into().expect("render step 3"); + assert!(html.contains(""), "Step 3: items should still render with Card: {}", html); + assert!(html.contains("Title A UPDATED"), "Step 3: content should be preserved: {}", html); +} + +/// Old keyed items' children keep resolved Card statics (frozen); +/// new items' children get the current Badge template. +/// Without expand_statics, all items switch to Badge (wrong for old items). +#[test] +fn new_keyed_items_get_current_template_old_items_keep_resolved() { + // Initial: keyed comp with inline statics, each item has a child fragment + // with TemplateRef(0) pointing to Card template (2 slots) + let initial_json = json!({ + "0": { + "k": { + "0": { + "0": { + "0": "Card A", + "1": "Detail A", + "s": 0 + } + }, + "1": { + "0": { + "0": "Card B", + "1": "Detail B", + "s": 0 + } + }, + "kc": 2 + }, + "s": ["", ""] + }, + "p": { + "0": ["", "", ""] + }, + "s": ["", ""] + }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("parse"); + let root: Root = root.try_into().expect("convert"); + let html: String = root.clone().try_into().expect("render initial"); + assert!(html.contains("Card A"), "Initial: {}", html); + + // Diff: template 0 changes to Badge (1 slot), and a new item "2" arrives + // with a child fragment that has TemplateRef(0) → should resolve to Badge. + // Old items 0,1 keep old positions (children already resolved to Card). + let diff_json = json!({ + "0": { + "k": { + "0": 0, + "1": 1, + "2": { + "0": { + "0": "New Badge", + "s": 0 + } + }, + "kc": 3 + } + }, + "p": { + "0": ["", ""] + } + }); + + let diff: RootDiff = serde_json::from_value(diff_json).expect("parse diff"); + let merged = root.merge(diff).expect("merge"); + let html: String = merged.try_into().expect("render after template change"); + + // Old items' children should still use Card (resolved before template change) + assert!(html.contains(""), "Old items should keep Card: {}", html); + assert!(html.contains("Card A"), "Old item A content preserved: {}", html); + assert!(html.contains("Card B"), "Old item B content preserved: {}", html); + + // New item's child should use Badge (current template 0) + assert!(html.contains("New Badge"), "New item should use Badge: {}", html); +} + +/// Templates are None after each expand_statics pass, so partial diffs +/// with only new template keys don't accumulate stale entries. +/// Verifies structural state (templates=None, statics resolved) after each step. +#[test] +fn template_deletion_prevents_stale_key_accumulation() { + // Initial: two children using templates 0 and 1, plus a third using inline statics + let initial_json = json!({ + "0": { + "0": "in box", + "s": 0 + }, + "1": { + "0": "in text", + "s": 1 + }, + "2": "plain string", + "p": { + "0": ["", ""], + "1": ["", ""] + }, + "s": ["", "", "", ""] + }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("parse"); + let root: Root = root.try_into().expect("convert"); + + // After initial convert, templates should be None (deleted by expand_statics) + match &root.fragment { + Fragment::Regular { templates, .. } => { + assert_eq!(*templates, None, "Templates should be deleted after expand_statics"); + } + _ => panic!("Expected Regular fragment"), + } + + // Verify initial render + let html: String = root.clone().try_into().expect("render initial"); + assert!(html.contains("in box"), "Initial Box: {}", html); + assert!(html.contains("in text"), "Initial Text: {}", html); + + // Diff: updates child 2 to a fragment using template 2, provides ONLY template 2. + // This is a partial template diff — it doesn't resend templates 0 and 1. + // This works because expand_statics already resolved children 0,1 and deleted templates. + let diff_json = json!({ + "2": { + "0": "in badge", + "s": 2 + }, + "p": { + "2": ["", ""] + } + }); + + let diff: RootDiff = serde_json::from_value(diff_json).expect("parse diff"); + let merged = root.merge(diff).expect("merge"); + + // After merge + expand_statics, templates should be None again + match &merged.fragment { + Fragment::Regular { templates, children, .. } => { + assert_eq!(*templates, None, "Templates should be None after second expand_statics"); + + // Children 0 and 1 should still have their resolved statics + for (key, expected_tag) in [("0", ""), ("1", "")] { + match children.get(key) { + Some(Child::Fragment(Fragment::Regular { statics, .. })) => { + if let Some(Statics::Statics(s)) = statics { + assert!( + s[0] == expected_tag, + "Child {} should have statics starting with {}. Got: {:?}", + key, expected_tag, s + ); + } else { + panic!("Child {} statics should be resolved. Got: {:?}", key, statics); + } + } + other => panic!("Child {} should be Regular fragment. Got: {:?}", key, other), + } + } + + // Child 2 should now be a fragment with resolved Badge statics + match children.get("2") { + Some(Child::Fragment(Fragment::Regular { statics, .. })) => { + assert!( + matches!(statics, Some(Statics::Statics(s)) if s[0] == ""), + "Child 2 should have Badge statics. Got: {:?}", + statics + ); + } + other => panic!("Child 2 should be Regular fragment. Got: {:?}", other), + } + } + _ => panic!("Expected Regular fragment"), + } + + // Render should produce all three + let html: String = merged.try_into().expect("render"); + assert!(html.contains("in box"), "Box: {}", html); + assert!(html.contains("in text"), "Text: {}", html); + assert!(html.contains("in badge"), "Badge: {}", html); +} + +/// Deep child resolved to Emphasis stays Emphasis when parent's template 0 +/// changes to Strikethrough. Without expand_statics, it switches. +#[test] +fn nested_template_inheritance_survives_parent_template_change() { + // Initial: parent has template 0 = Emphasis, deeply nested child uses it + let initial_json = json!({ + "0": { + "0": { + "0": "deep content", + "s": 0 + }, + "s": ["", ""] + }, + "p": { + "0": ["", ""] + }, + "s": ["", ""] + }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("parse"); + let root: Root = root.try_into().expect("convert"); + let html: String = root.clone().try_into().expect("render initial"); + assert!(html.contains("deep content"), "Initial: {}", html); + + // Diff: parent's template 0 changes to Strikethrough + // Deep child already resolved to Emphasis — should be immune + let diff_json = json!({ + "p": { + "0": ["", ""] + } + }); + + let diff: RootDiff = serde_json::from_value(diff_json).expect("parse diff"); + let merged = root.merge(diff).expect("merge"); + let html: String = merged.try_into().expect("render after template change"); + + // Deep child keeps Emphasis (already resolved), NOT Strikethrough + assert!( + html.contains("deep content"), + "Deep child should keep resolved Emphasis, not switch to Strikethrough: {}", html + ); +} + +// ==================================================================== +// Phoenix LiveView conformance tests +// Ported from: phoenix_live_view/assets/test/rendered_test.ts +// These tests use the exact JSON payloads from Phoenix's JS test suite +// to verify our template resolution and merge behavior matches Phoenix. +// ==================================================================== + +/// Port of Phoenix rendered_test.ts: toString("stringifies a diff") +/// Tests basic rendering with statics and dynamic children after a merge. +#[test] +fn phoenix_conformance_simple_render() { + // simpleDiff1 from rendered_test.ts + let initial_json = json!({ + "0": "cooling", + "1": "cooling", + "2": "07:15:03 PM", + "s": [ + "
    \n
    \n ", + "\n ", + "\n
    \n
    \n" + ], + "r": 1 + }); + + // simpleDiff2 from rendered_test.ts + let diff_json = json!({ + "2": "07:15:04 PM" + }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("parse initial"); + let root: Root = root.try_into().expect("convert initial"); + let diff: RootDiff = serde_json::from_value(diff_json).expect("parse diff"); + let merged = root.merge(diff).expect("merge"); + let html: String = merged.try_into().expect("render"); + + // Phoenix expected output (minus data-phx-id attribute which our code doesn't generate) + let expected = concat!( + "
    \n", + "
    \n", + " cooling\n", + " 07:15:04 PM\n", + "
    \n", + "
    \n", + ); + assert_eq!(html, expected); +} + +/// Port of Phoenix rendered_test.ts: +/// toString("reuses static in components and comprehensions") +/// +/// This is the key template conformance test. It exercises: +/// - Template refs (TEMPLATES / "p" key) with TemplateRef statics (s: 0) +/// - Nested keyed comprehensions +/// - Component references (ComponentRef statics) +/// - Template inheritance from parent to child fragments +#[test] +fn phoenix_conformance_static_reuse_with_templates_and_components() { + // staticReuseDiff from rendered_test.ts + let json_data = json!({ + "0": { + "k": { + "kc": 2, + "0": { + "0": "foo", + "1": { + "k": { + "kc": 2, + "0": { "0": "0", "1": 1 }, + "1": { "0": "1", "1": 2 } + }, + "s": 0 + } + }, + "1": { + "0": "bar", + "1": { + "k": { + "kc": 2, + "0": { "0": "0", "1": 3 }, + "1": { "0": "1", "1": 4 } + }, + "s": 0 + } + } + }, + "s": ["\n

    \n ", "\n ", "\n

    \n"], + "r": 1, + "p": { "0": ["", ": ", ""] } + }, + "c": { + "1": { + "0": "index_1", + "1": "world", + "s": ["FROM ", " ", ""], + "r": 1 + }, + "2": { "0": "index_2", "1": "world", "s": 1, "r": 1 }, + "3": { "0": "index_1", "1": "world", "s": 1, "r": 1 }, + "4": { "0": "index_2", "1": "world", "s": 3, "r": 1 } + }, + "s": ["
    ", "
    "], + "r": 1 + }); + + let root: RootDiff = serde_json::from_value(json_data).expect("parse"); + let root: Root = root.try_into().expect("convert"); + let html: String = root.try_into().expect("render"); + + // Phoenix expected output (minus data-phx-* attributes) + let expected = concat!( + "
    ", + "\n

    \n foo\n ", + "0: FROM index_1 world", + "1: FROM index_2 world", + "\n

    \n", + "\n

    \n bar\n ", + "0: FROM index_1 world", + "1: FROM index_2 world", + "\n

    \n", + "
    ", + ); + assert_eq!(html, expected); +} + +/// Port of Phoenix rendered_test.ts: +/// mergeDiff("merges the latter diff if it contains a `static` key") +/// +/// When a diff includes statics, the entire fragment is replaced (not merged). +#[test] +fn phoenix_conformance_merge_replaces_on_new_static() { + let initial_json = json!({ "0": ["a"], "1": ["b"] }); + let diff_json = json!({ "0": ["c"], "s": ["c"] }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("parse initial"); + let root: Root = root.try_into().expect("convert"); + let diff: RootDiff = serde_json::from_value(diff_json).expect("parse diff"); + let merged = root.merge(diff).expect("merge"); + + // After replacement, statics=["c"] with 0 dynamic slots -> renders "c" + // Child "1" from the original should be gone (full replacement) + let html: String = merged.try_into().expect("render"); + assert_eq!(html, "c"); +} + +/// Port of Phoenix rendered_test.ts: +/// mergeDiff("merges the latter diff if it contains a `static` key even when nested") +#[test] +fn phoenix_conformance_merge_replaces_on_new_static_nested() { + let initial_json = json!({ "0": { "0": ["a"], "1": ["b"] } }); + let diff_json = json!({ "0": { "0": ["c"], "s": ["c"] } }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("parse initial"); + let root: Root = root.try_into().expect("convert"); + let diff: RootDiff = serde_json::from_value(diff_json).expect("parse diff"); + let merged = root.merge(diff).expect("merge"); + + // Outer fragment has no statics so we verify the inner structure + match &merged.fragment { + Fragment::Regular { children, statics, .. } => { + assert!(statics.is_none(), "Outer fragment should have no statics"); + match children.get("0") { + Some(Child::Fragment(Fragment::Regular { + statics: inner_statics, + children: inner_children, + .. + })) => { + assert_eq!( + *inner_statics, + Some(Statics::Statics(vec!["c".to_string()])) + ); + // Child "1" from original should be gone after replacement + assert!( + inner_children.get("1").is_none(), + "Child 1 should not exist after replacement" + ); + } + other => panic!("Expected inner Regular fragment, got: {:?}", other), + } + } + other => panic!("Expected Regular fragment, got: {:?}", other), + } +} + +/// Port of Phoenix rendered_test.ts: +/// mergeDiff("replaces a string when a map is returned") +#[test] +fn phoenix_conformance_merge_string_to_map() { + let initial_json = json!({ "0": { "0": "", "s": "" } }); + let diff_json = json!({ "0": { "0": { "0": "val", "s": "" }, "s": "" } }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("parse initial"); + let root: Root = root.try_into().expect("convert"); + + // Verify initial: child 0's child 0 is a string + match &root.fragment { + Fragment::Regular { children, .. } => { + if let Some(Child::Fragment(Fragment::Regular { children: inner, .. })) = + children.get("0") + { + assert!( + matches!(inner.get("0"), Some(Child::String(_))), + "Initial child 0.0 should be a string" + ); + } + } + _ => panic!("Expected Regular fragment"), + } + + let diff: RootDiff = serde_json::from_value(diff_json).expect("parse diff"); + let merged = root.merge(diff).expect("merge"); + + // After merge: child 0's child 0 should now be a fragment (was a string) + match &merged.fragment { + Fragment::Regular { children, .. } => { + if let Some(Child::Fragment(Fragment::Regular { children: inner, .. })) = + children.get("0") + { + assert!( + matches!(inner.get("0"), Some(Child::Fragment(_))), + "After merge, child 0.0 should be a fragment (was string)" + ); + } else { + panic!("Expected fragment for child 0"); + } + } + _ => panic!("Expected Regular fragment"), + } +} + +/// Port of Phoenix rendered_test.ts: +/// mergeDiff("replaces a map when a string is returned") +#[test] +fn phoenix_conformance_merge_map_to_string() { + let initial_json = json!({ "0": { "0": { "0": "val", "s": "" }, "s": "" } }); + let diff_json = json!({ "0": { "0": "", "s": "" } }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("parse initial"); + let root: Root = root.try_into().expect("convert"); + + // Verify initial: child 0's child 0 is a fragment + match &root.fragment { + Fragment::Regular { children, .. } => { + if let Some(Child::Fragment(Fragment::Regular { children: inner, .. })) = + children.get("0") + { + assert!( + matches!(inner.get("0"), Some(Child::Fragment(_))), + "Initial child 0.0 should be a fragment" + ); + } + } + _ => panic!("Expected Regular fragment"), + } + + let diff: RootDiff = serde_json::from_value(diff_json).expect("parse diff"); + let merged = root.merge(diff).expect("merge"); + + // After merge: child 0's child 0 should now be a string (was a fragment) + match &merged.fragment { + Fragment::Regular { children, .. } => { + if let Some(Child::Fragment(Fragment::Regular { children: inner, .. })) = + children.get("0") + { + assert!( + matches!(inner.get("0"), Some(Child::String(_))), + "After merge, child 0.0 should be a string (was fragment)" + ); + } else { + panic!("Expected fragment for child 0"); + } + } + _ => panic!("Expected Regular fragment"), + } +} + +/// Port of Phoenix rendered_test.ts: +/// mergeDiff("recursively merges two diffs") — deep part +/// +/// Tests keyed comprehension merge with partial updates. +/// deepDiff1 has no root statics (not renderable), so we verify structure. +#[test] +fn phoenix_conformance_deep_diff_keyed_merge() { + // deepDiff1 from rendered_test.ts + let initial_json = json!({ + "0": { + "0": { + "k": { + "0": { "0": "user1058", "1": "1" }, + "1": { "0": "user99", "1": "1" }, + "kc": 2 + }, + "s": [ + " \n ", + " (", + ")\n \n" + ], + "r": 1 + }, + "s": [ + " \n \n \n \n \n \n \n \n", + " \n
    Username
    \n" + ], + "r": 1 + }, + "1": { + "k": { + "0": { + "0": "asdf_asdf", + "1": "asdf@asdf.com", + "2": "123-456-7890", + "3": "Show", + "4": "Edit", + "5": "Delete" + }, + "kc": 1 + }, + "s": [ + " \n ", + "\n ", + "\n ", + "\n\n \n", + " ", + "\n", + " \n \n" + ], + "r": 1 + } + }); + + // deepDiff2 from rendered_test.ts + let diff_json = json!({ + "0": { + "0": { + "k": { + "0": { "0": "user1058", "1": "2" }, + "kc": 1 + } + } + } + }); + + let root: RootDiff = serde_json::from_value(initial_json).expect("parse initial"); + let root: Root = root.try_into().expect("convert"); + let diff: RootDiff = serde_json::from_value(diff_json).expect("parse diff"); + let merged = root.merge(diff).expect("merge"); + + // Verify the inner keyed comp was updated: + // - key_count should be 1 (was 2) + // - item "0" should have "1" = "2" (was "1") + match &merged.fragment { + Fragment::Regular { children, .. } => { + let child_0 = children.get("0").expect("child 0"); + if let Child::Fragment(Fragment::Regular { children: inner, .. }) = child_0 { + let child_0_0 = inner.get("0").expect("child 0.0"); + if let Child::Fragment(Fragment::KeyedComprehension { keyed, .. }) = child_0_0 { + assert_eq!(keyed.key_count, 1, "key_count should be 1 after merge"); + let item_0 = keyed.items.get("0").expect("keyed item 0"); + if let KeyedItem::Fragment(frag) = item_0 { + if let Fragment::Regular { children: item_children, .. } = frag.as_ref() { + let val = item_children.get("1").expect("child 1 of item 0"); + assert_eq!( + *val, + Child::String(OneOrManyStrings::One("2".to_string())), + "item 0's child 1 should be '2' after merge" + ); + } else { + panic!("Expected Regular fragment for keyed item"); + } + } else { + panic!("Expected Fragment keyed item"); + } + } else { + panic!("Expected KeyedComprehension for child 0.0"); + } + } else { + panic!("Expected Fragment for child 0"); + } + } + _ => panic!("Expected Regular fragment"), + } +} + diff --git a/crates/core/src/diff/fragment/wasm.rs b/crates/core/src/diff/fragment/wasm.rs index c5c3b721..9a1c1793 100644 --- a/crates/core/src/diff/fragment/wasm.rs +++ b/crates/core/src/diff/fragment/wasm.rs @@ -29,9 +29,9 @@ impl Root { impl Fragment { pub fn is_new_fingerprint(&self) -> bool { match self { - Fragment::Regular { statics, .. } | Fragment::Comprehension { statics, .. } => { - statics.is_some() - } + Fragment::Regular { statics, .. } + | Fragment::Comprehension { statics, .. } + | Fragment::KeyedComprehension { statics, .. } => statics.is_some(), } } @@ -45,6 +45,13 @@ impl Fragment { stream: None, new_render: None, } => dynamics.is_empty(), + Fragment::KeyedComprehension { + keyed, + statics: None, + is_root: None, + templates: None, + new_render: None, + } => keyed.items.is_empty(), _ => false, } } diff --git a/crates/core/src/dom/mod.rs b/crates/core/src/dom/mod.rs index 635951b1..3ac649cd 100644 --- a/crates/core/src/dom/mod.rs +++ b/crates/core/src/dom/mod.rs @@ -1243,3 +1243,435 @@ impl petgraph::visit::Visitable for Document { map.grow(self.nodes.len()) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Test that conditional element properly hides when condition becomes false. + /// This tests the full pipeline: fragment merge → render → parse → diff → patch. + #[test] + fn document_merge_conditional_hides() { + // Initial state: conditional element is shown (routes == []) + let initial_json = json!({ + "0": {"d": [], "s": ["", ""]}, // Empty comprehension + "1": {"s": [""]}, // Conditional shown + "s": ["", "", "", ""] + }); + + let mut doc = Document::parse_fragment_json(initial_json.to_string()) + .expect("Failed to parse initial fragment"); + + // Verify initial state has the Empty element + let initial_html = doc.to_string(); + assert!( + initial_html.contains("
    "]}, + "s": 1 + }, + "p": { + "0": ["", ""], + "1": ["", "", ""] + }, + "s": ["", ""] + }); + + let mut doc = Document::parse_fragment_json(initial_json.to_string()) + .expect("Failed to parse initial fragment"); + + let html = doc.to_string(); + assert!(html.contains("No routes available"), "Initial: Should show empty state. Got: {}", html); + + // Diff: add routes, hide empty state + let diff1 = json!({ + "0": { + "0": { + "k": { + "0": {"0": "Route ABC"}, + "kc": 1 + } + }, + "1": "" + } + }); + + doc.merge_fragment_json(diff1).expect("merge diff1"); + let html1 = doc.to_string(); + assert!(!html1.contains("No routes available"), "After adding route: Empty state should be gone. Got: {}", html1); + assert!(html1.contains("Route ABC"), "After adding route: Should show route. Got: {}", html1); + + // Diff: remove routes, show empty state + let diff2 = json!({ + "0": { + "0": { + "k": {"kc": 0} + }, + "1": {"s": ["No routes available"]} + } + }); + + doc.merge_fragment_json(diff2).expect("merge diff2"); + let html2 = doc.to_string(); + assert!(html2.contains("No routes available"), "After removing routes: Empty state should reappear. Got: {}", html2); + assert!(!html2.contains("Route ABC"), "After removing routes: Route should be gone. Got: {}", html2); + assert_eq!(html2.matches("No routes available").count(), 1, + "Should have exactly one empty state message (no duplication). Got: {}", html2); + + // One more toggle cycle to verify no accumulation + let diff3 = json!({ + "0": { + "0": { + "k": { + "0": {"0": "Route XYZ"}, + "kc": 1 + } + }, + "1": "" + } + }); + doc.merge_fragment_json(diff3).expect("merge diff3"); + let html3 = doc.to_string(); + assert!(!html3.contains("No routes available"), "Toggle 3: Empty state should be gone. Got: {}", html3); + assert!(html3.contains("Route XYZ"), "Toggle 3: Should show new route. Got: {}", html3); + + let diff4 = json!({ + "0": { + "0": { + "k": {"kc": 0} + }, + "1": {"s": ["No routes available"]} + } + }); + doc.merge_fragment_json(diff4).expect("merge diff4"); + let html4 = doc.to_string(); + assert_eq!(html4.matches("No routes available").count(), 1, + "After 4 toggles: Should still have exactly one empty state. Got: {}", html4); + } + + /// Helper to count only element nodes (not leaf/text nodes) + fn count_elements_recursive(doc: &Document, node_ref: NodeRef) -> usize { + let node = doc.get(node_ref); + let children = doc.children(node_ref); + + match node { + NodeData::Root => { + children.iter().map(|child| count_elements_recursive(doc, *child)).sum() + } + NodeData::Leaf { .. } => { + // Leaf nodes don't count as elements + 0 + } + NodeData::NodeElement { .. } => { + // Count this element plus all child elements + 1 + children.iter().map(|child| count_elements_recursive(doc, *child)).sum::() + } + } + } + + /// Test that verifies children counts match what walkThroughDOM would see. + /// This catches bugs where the DOM tree has extra nodes that HTML rendering might not show. + #[test] + fn document_merge_children_count_matches() { + // Initial state: empty list, conditional shown + let initial_json = json!({ + "0": {"d": [], "s": ["", ""]}, + "1": {"s": [""]}, + "s": ["", "", "", ""] + }); + + let mut doc = Document::parse_fragment_json(initial_json.to_string()) + .expect("Failed to parse initial fragment"); + + // Initial: Root should have 2 direct children (the empty comprehension result and Empty element) + // But comprehension is empty so just Empty element is visible + let initial_count = count_elements_recursive(&doc, doc.root()); + + // Toggle 1: hide empty, show item + let diff1 = json!({ + "0": {"d": [["A"]]}, + "1": "" + }); + doc.merge_fragment_json(diff1).expect("merge diff1"); + + // Toggle 2: show empty, hide items + let diff2 = json!({ + "0": {"d": []}, + "1": {"s": [""]} + }); + doc.merge_fragment_json(diff2).expect("merge diff2"); + + let count2 = count_elements_recursive(&doc, doc.root()); + + // The element count should be the same as initial + assert_eq!(count2, initial_count, + "After toggling back: element count should match initial. Got {} vs {}", + count2, initial_count); + + // Toggle 3: hide empty again + let diff3 = json!({ + "0": {"d": [["B"]]}, + "1": "" + }); + doc.merge_fragment_json(diff3).expect("merge diff3"); + + // Toggle 4: show empty again - verify no accumulation + let diff4 = json!({ + "0": {"d": []}, + "1": {"s": [""]} + }); + doc.merge_fragment_json(diff4).expect("merge diff4"); + + let count4 = count_elements_recursive(&doc, doc.root()); + + assert_eq!(count4, initial_count, + "After 4 toggles: element count should still match initial. Got {} vs {}", + count4, initial_count); + } + + /// Test case: keyed comprehension with conditional children + /// that reference templates which get overwritten by new template partials. + /// + /// This test uses sequential child indices (0,1,2,3,4) since template slots are positional. + /// Children 3 and 4 are conditional (initially hidden as empty strings). + #[test] + fn document_merge_keyed_item_conditional_children_template_overwrite() { + // Simplified version of the scenario: + // - Keyed comprehension uses template 0 (Card template with 5 slots: 0,1,2,3,4) + // - Inside keyed item, children 3,4 are conditional (empty strings initially) + // - When children 3,4 become visible, they reference templates 2,3 (different from Card's template 0) + // - Phoenix sends NEW templates at indices 2,3 in the diff + // - Template memory should preserve Card template for keyed comprehension + + let initial_json = json!({ + "0": { + "0": { + "k": { + "0": { + "0": "Route 107", + "1": " color=\"#FFFFA726\"", // Dynamic attribute + "2": "Preparing", + "3": "", // Hidden conditional (Actual time row) + "4": "" // Hidden conditional (Progress bar) + }, + "kc": 1 + }, + "s": 0 // Keyed comprehension uses template 0 (Card) + }, + "1": "", + "s": 1 + }, + "p": { + // Card template with 6 statics (5 dynamic slots for children 0,1,2,3,4) + "0": ["", "", "", "", ""], + "1": ["", "", ""] + }, + "s": ["", ""] + }); + + let mut doc = Document::parse_fragment_json(initial_json.to_string()) + .expect("parse initial"); + + let html = doc.to_string(); + assert!(html.contains(""), "Initial should have Card: {}", html); + assert!(html.contains("Route 107"), "Initial should have route: {}", html); + + // Diff: route status changes, conditionals become visible + // Phoenix sends NEW templates at indices 2,3 for the conditional children + // (different from template 0 used by KeyedComprehension) + let diff = json!({ + "0": { + "0": { + "k": { + "0": { + "0": "Route 107", + "1": " color=\"#FF42A5F5\"", // Color changed + "2": "En route", + "3": {"0": "12:02", "1": "17:23", "s": 2}, // Now visible, uses template 2 + "4": {"0": "0.5", "s": 3} // Now visible, uses template 3 + }, + "kc": 1 + } + } + }, + "p": { + "2": ["", "", ""], // NEW template 2 for child 3 + "3": [""] // NEW template 3 for child 4 + } + }); + + // This should succeed because templates 2,3 don't conflict with template 0 + let result = doc.merge_fragment_json(diff); + + // After fix: should succeed + assert!(result.is_ok(), "Merge should succeed, got: {:?}", result.err()); + + let html2 = doc.to_string(); + + // Card should still be present (template preserved) + assert!(html2.contains(""), "Card should still render after diff: {}", html2); + + // Route content should be present + assert!(html2.contains("Route 107"), "Route should be visible: {}", html2); + + // Conditional content should also be present (rendered via templates 2,3) + assert!(html2.contains("12:02"), "Actual time should appear: {}", html2); + assert!(html2.contains("17:23"), "End time should appear: {}", html2); + } +}