use super::HandlerPlugin;
use crate::{HandlerError, ResponseWriter, Result};
use serde_json::Value;
use std::fmt::Write;
use webui_protocol::FastElementData;
const BINDING_START_PREFIX: &str = "<!--fe-b$$start$$";
const BINDING_END_PREFIX: &str = "<!--fe-b$$end$$";
const BINDING_SUFFIX: &str = "$$fe-b-->";
const SEPARATOR: &str = "$$";
const REPEAT_START_PREFIX: &str = "<!--fe-repeat$$start$$";
const REPEAT_END_PREFIX: &str = "<!--fe-repeat$$end$$";
const REPEAT_SUFFIX: &str = "$$fe-repeat-->";
const ATTR_SINGLE_PREFIX: &str = " data-fe-b-";
const ATTR_MULTI_PREFIX: &str = " data-fe-c-";
pub struct FastHydrationPlugin {
scopes: Vec<usize>,
binding_stack: Vec<usize>,
buffer: String,
}
impl FastHydrationPlugin {
#[must_use]
pub fn new() -> Self {
Self {
scopes: vec![0],
binding_stack: Vec::with_capacity(8),
buffer: String::with_capacity(64),
}
}
fn is_active(&self) -> bool {
self.scopes.len() > 1
}
fn next_index(&mut self) -> usize {
if let Some(counter) = self.scopes.last_mut() {
let index = *counter;
*counter += 1;
index
} else {
0
}
}
fn next_index_n(&mut self, count: u32) -> usize {
if let Some(counter) = self.scopes.last_mut() {
let index = *counter;
*counter += count as usize;
index
} else {
0
}
}
fn build_binding_comment(&mut self, prefix: &str, index: usize, name: &str) {
self.buffer.clear();
self.buffer.push_str(prefix);
let _ = write!(self.buffer, "{}", index);
self.buffer.push_str(SEPARATOR);
self.buffer.push_str(name);
self.buffer.push_str(BINDING_SUFFIX);
}
fn build_repeat_comment(&mut self, prefix: &str, index: usize) {
self.buffer.clear();
self.buffer.push_str(prefix);
let _ = write!(self.buffer, "{}", index);
self.buffer.push_str(REPEAT_SUFFIX);
}
fn build_attribute_marker(&mut self, binding_index: usize, count: u32) {
self.buffer.clear();
if count == 1 {
self.buffer.push_str(ATTR_SINGLE_PREFIX);
let _ = write!(self.buffer, "{}", binding_index);
} else {
self.buffer.push_str(ATTR_MULTI_PREFIX);
let _ = write!(self.buffer, "{}-{}", binding_index, count);
}
}
}
impl Default for FastHydrationPlugin {
fn default() -> Self {
Self::new()
}
}
impl HandlerPlugin for FastHydrationPlugin {
fn push_scope(&mut self) {
self.scopes.push(0);
}
fn pop_scope(&mut self) {
self.scopes.pop();
}
fn on_binding_start(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
if !self.is_active() {
return Ok(());
}
let index = self.next_index();
self.binding_stack.push(index);
self.build_binding_comment(BINDING_START_PREFIX, index, name);
writer.write(&self.buffer)
}
fn on_binding_end(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
if !self.is_active() {
return Ok(());
}
let index = self.binding_stack.pop().unwrap_or(0);
self.build_binding_comment(BINDING_END_PREFIX, index, name);
writer.write(&self.buffer)
}
fn on_repeat_item_start(
&mut self,
index: usize,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
if !self.is_active() {
return Ok(());
}
self.build_repeat_comment(REPEAT_START_PREFIX, index);
writer.write(&self.buffer)
}
fn on_repeat_item_end(&mut self, index: usize, writer: &mut dyn ResponseWriter) -> Result<()> {
if !self.is_active() {
return Ok(());
}
self.build_repeat_comment(REPEAT_END_PREFIX, index);
writer.write(&self.buffer)
}
fn on_element_data(&mut self, data: &[u8], writer: &mut dyn ResponseWriter) -> Result<()> {
if !self.is_active() {
return Ok(());
}
let decoded = FastElementData::decode(data).map_err(|error| {
HandlerError::PluginData(format!(
"FAST hydration plugin expected 4 bytes of element data: {error}"
))
})?;
if decoded.binding_count > 0 {
let binding_index = self.next_index_n(decoded.binding_count);
self.build_attribute_marker(binding_index, decoded.binding_count);
writer.write(&self.buffer)?;
}
Ok(())
}
fn write_route_component_state(
&self,
state: &Value,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
let map = match state.as_object() {
Some(m) => m,
None => return Ok(()),
};
for (key, value) in map {
let val_str = match value {
Value::String(s) => std::borrow::Cow::Borrowed(s.as_str()),
Value::Number(n) => std::borrow::Cow::Owned(n.to_string()),
Value::Bool(true) => std::borrow::Cow::Borrowed("true"),
Value::Bool(false) => std::borrow::Cow::Borrowed("false"),
_ => continue,
};
let attr_name = crate::camel_to_kebab(key);
writer.write(" ")?;
writer.write(&attr_name)?;
writer.write("=\"")?;
crate::route_renderer::write_escaped_state_attr(writer, val_str.as_ref())?;
writer.write("\"")?;
}
let has_complex = map.values().any(|v| v.is_array() || v.is_object());
if has_complex {
let json_str = state.to_string();
writer.write(" data-state=\"")?;
crate::route_renderer::write_escaped_state_attr(writer, &json_str)?;
writer.write("\"")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestWriter {
output: String,
}
impl TestWriter {
fn new() -> Self {
Self {
output: String::new(),
}
}
}
impl ResponseWriter for TestWriter {
fn write(&mut self, content: &str) -> Result<()> {
self.output.push_str(content);
Ok(())
}
fn end(&mut self) -> Result<()> {
Ok(())
}
}
#[test]
fn test_root_scope_disabled() {
let mut plugin = FastHydrationPlugin::new();
let mut writer = TestWriter::new();
plugin.on_binding_start("x", &mut writer).unwrap();
plugin.on_binding_end("x", &mut writer).unwrap();
plugin.on_repeat_item_start(0, &mut writer).unwrap();
plugin.on_repeat_item_end(0, &mut writer).unwrap();
let data = 3u32.to_le_bytes();
plugin.on_element_data(&data, &mut writer).unwrap();
assert_eq!(writer.output, "");
}
#[test]
fn test_binding_start_format() {
let mut plugin = FastHydrationPlugin::new();
plugin.push_scope();
let mut writer = TestWriter::new();
plugin.on_binding_start("userName", &mut writer).unwrap();
assert_eq!(writer.output, "<!--fe-b$$start$$0$$userName$$fe-b-->");
}
#[test]
fn test_binding_end_format() {
let mut plugin = FastHydrationPlugin::new();
plugin.push_scope();
let mut writer = TestWriter::new();
plugin.on_binding_start("userName", &mut writer).unwrap();
writer.output.clear();
plugin.on_binding_end("userName", &mut writer).unwrap();
assert_eq!(writer.output, "<!--fe-b$$end$$0$$userName$$fe-b-->");
}
#[test]
fn test_binding_index_increments() {
let mut plugin = FastHydrationPlugin::new();
plugin.push_scope();
let mut writer = TestWriter::new();
plugin.on_binding_start("a", &mut writer).unwrap();
plugin.on_binding_end("a", &mut writer).unwrap();
writer.output.clear();
plugin.on_binding_start("b", &mut writer).unwrap();
assert_eq!(writer.output, "<!--fe-b$$start$$1$$b$$fe-b-->");
}
#[test]
fn test_scope_resets_counter() {
let mut plugin = FastHydrationPlugin::new();
let mut writer = TestWriter::new();
plugin.push_scope();
plugin.on_binding_start("a", &mut writer).unwrap();
plugin.on_binding_end("a", &mut writer).unwrap();
plugin.push_scope();
writer.output.clear();
plugin.on_binding_start("b", &mut writer).unwrap();
assert_eq!(writer.output, "<!--fe-b$$start$$0$$b$$fe-b-->");
plugin.on_binding_end("b", &mut writer).unwrap();
plugin.pop_scope();
writer.output.clear();
plugin.on_binding_start("c", &mut writer).unwrap();
assert_eq!(writer.output, "<!--fe-b$$start$$1$$c$$fe-b-->");
}
#[test]
fn test_repeat_item_markers() {
let mut plugin = FastHydrationPlugin::new();
plugin.push_scope();
let mut writer = TestWriter::new();
plugin.on_repeat_item_start(0, &mut writer).unwrap();
assert_eq!(writer.output, "<!--fe-repeat$$start$$0$$fe-repeat-->");
writer.output.clear();
plugin.on_repeat_item_end(0, &mut writer).unwrap();
assert_eq!(writer.output, "<!--fe-repeat$$end$$0$$fe-repeat-->");
}
#[test]
fn test_attribute_binding_single() {
let mut plugin = FastHydrationPlugin::new();
plugin.push_scope();
let mut writer = TestWriter::new();
let data = 1u32.to_le_bytes();
plugin.on_element_data(&data, &mut writer).unwrap();
assert_eq!(writer.output, " data-fe-b-0");
}
#[test]
fn test_attribute_binding_multi() {
let mut plugin = FastHydrationPlugin::new();
plugin.push_scope();
let mut writer = TestWriter::new();
let data = 3u32.to_le_bytes();
plugin.on_element_data(&data, &mut writer).unwrap();
assert_eq!(writer.output, " data-fe-c-0-3");
}
#[test]
fn test_attribute_binding_zero_count_no_output() {
let mut plugin = FastHydrationPlugin::new();
plugin.push_scope();
let mut writer = TestWriter::new();
let data = 0u32.to_le_bytes();
plugin.on_element_data(&data, &mut writer).unwrap();
assert_eq!(writer.output, "");
}
#[test]
fn test_attribute_binding_advances_counter() {
let mut plugin = FastHydrationPlugin::new();
plugin.push_scope();
let mut writer = TestWriter::new();
let data = 3u32.to_le_bytes();
plugin.on_element_data(&data, &mut writer).unwrap();
writer.output.clear();
plugin.on_binding_start("x", &mut writer).unwrap();
assert_eq!(writer.output, "<!--fe-b$$start$$3$$x$$fe-b-->");
}
#[test]
fn test_nested_scopes_independent_counters() {
let mut plugin = FastHydrationPlugin::new();
let mut writer = TestWriter::new();
plugin.push_scope();
plugin.on_binding_start("root", &mut writer).unwrap();
plugin.on_binding_end("root", &mut writer).unwrap();
plugin.push_scope();
writer.output.clear();
plugin.on_binding_start("for-1", &mut writer).unwrap();
assert!(writer.output.contains("start$$0$$for-1"));
plugin.push_scope();
writer.output.clear();
plugin.on_binding_start("signal", &mut writer).unwrap();
assert!(writer.output.contains("start$$0$$signal"));
plugin.on_binding_end("signal", &mut writer).unwrap();
plugin.pop_scope();
plugin.on_binding_end("for-1", &mut writer).unwrap();
plugin.pop_scope();
writer.output.clear();
plugin.on_binding_start("root2", &mut writer).unwrap();
assert!(writer.output.contains("start$$1$$root2"));
}
#[test]
fn test_empty_element_data_returns_error() {
let mut plugin = FastHydrationPlugin::new();
plugin.push_scope();
let mut writer = TestWriter::new();
let result = plugin.on_element_data(&[], &mut writer);
assert!(
matches!(result, Err(crate::HandlerError::PluginData(ref msg)) if msg.contains("expected 4 bytes")),
"invalid payload length should produce a plugin-data error: {result:?}"
);
assert_eq!(writer.output, "");
}
#[test]
fn test_short_element_data_returns_error() {
let mut plugin = FastHydrationPlugin::new();
plugin.push_scope();
let mut writer = TestWriter::new();
let result = plugin.on_element_data(&[1, 2], &mut writer);
assert!(
matches!(result, Err(crate::HandlerError::PluginData(ref msg)) if msg.contains("expected 4 bytes")),
"invalid payload length should produce a plugin-data error: {result:?}"
);
assert_eq!(writer.output, "");
}
#[test]
fn test_write_route_component_state_emits_data_state() {
let plugin = FastHydrationPlugin::new();
let mut writer = TestWriter::new();
let state = serde_json::json!({
"title": "Hello",
"items": [{"name": "A&B"}]
});
plugin
.write_route_component_state(&state, &mut writer)
.unwrap();
assert!(
writer.output.contains("data-state="),
"FAST plugin should emit data-state: {}",
writer.output
);
assert!(
writer.output.contains(r#"title="Hello""#),
"FAST plugin should still emit scalar attrs: {}",
writer.output
);
}
use std::collections::HashMap;
use webui_protocol::{
web_ui_fragment, ConditionExpr, FragmentList, LogicalOperator, WebUIFragment,
WebUIFragmentAttribute, WebUIProtocol,
};
use webui_test_utils::test_json;
use crate::{RenderOptions, WebUIHandler};
fn render_with_plugin(
protocol: &WebUIProtocol,
state: &serde_json::Value,
factory: fn() -> Box<dyn HandlerPlugin>,
) -> String {
let mut writer = TestWriter::new();
let handler = WebUIHandler::with_plugin(factory);
handler
.handle(
protocol,
state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
writer.output
}
fn render_no_plugin(protocol: &WebUIProtocol, state: &serde_json::Value) -> String {
let mut writer = TestWriter::new();
let handler = WebUIHandler::new();
handler
.handle(
protocol,
state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
writer.output
}
#[test]
fn test_no_plugin_no_markers() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("name", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"name": "Alice"});
let output = render_no_plugin(&protocol, &state);
assert_eq!(output, "<p>Alice</p>");
assert!(!output.contains("fe-b"));
}
#[test]
fn test_hydration_signal_markers() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("name", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"name": "Alice"});
let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
assert_eq!(output, "<p>Alice</p>");
}
#[test]
fn test_hydration_for_loop_markers() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop("item", "items", "for-1")],
},
);
fragments.insert(
"for-1".to_string(),
FragmentList {
fragments: vec![WebUIFragment::signal("item", false)],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"items": ["a", "b"]});
let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
assert!(!output.contains("$$for-1$$"));
assert!(!output.contains("fe-repeat"));
let item_pattern = "<!--fe-b$$start$$0$$item$$fe-b-->";
assert_eq!(output.matches(item_pattern).count(), 2);
}
#[test]
fn test_hydration_if_condition_markers() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::if_cond(
ConditionExpr::identifier("show"),
"if-1",
)],
},
);
fragments.insert(
"if-1".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<p>Visible</p>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"show": true});
let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
assert!(output.contains("<p>Visible</p>"));
assert!(!output.contains("fe-b"));
let state = test_json!({"show": false});
let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
assert!(!output.contains("<p>Visible</p>"));
assert!(!output.contains("fe-b"));
}
#[test]
fn test_hydration_component_scope_reset() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::signal("before", false),
WebUIFragment::component("my-comp"),
WebUIFragment::signal("after", false),
],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![WebUIFragment::signal("inner", false)],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"before": "B", "inner": "I", "after": "A"});
let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
assert!(!output.contains("$$before$$"));
assert!(!output.contains("$$after$$"));
assert!(output.contains("<!--fe-b$$start$$0$$inner$$fe-b-->"));
assert!(output.contains("<!--fe-b$$end$$0$$inner$$fe-b-->"));
}
#[test]
fn test_hydration_plugin_data_fragment() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div"),
WebUIFragment::attribute("id", "itemId"),
WebUIFragment::attribute("title", "itemTitle"),
WebUIFragment::plugin(2u32.to_le_bytes().to_vec()),
WebUIFragment::raw(">content</div>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"itemId": "42", "itemTitle": "Hello"});
let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
assert!(!output.contains("data-fe-c"));
assert!(output.contains("id=\"42\""));
assert!(output.contains("title=\"Hello\""));
}
#[test]
fn test_hydration_no_markers_in_mixed_attribute_value() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".to_string(),
template: "attr-title".to_string(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"attr-title".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Hello "),
WebUIFragment::signal("name", false),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("content", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"name": "World", "content": "CONTENT"});
let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
assert!(
output.contains("<!--fe-b"),
"Expected hydration markers in output"
);
assert!(
output.contains("title=\"Hello World\""),
"Expected clean attribute value without markers, got: {output}"
);
let title_start = output.find("title=\"").unwrap();
let title_end = output[title_start..].find('"').unwrap()
+ output[title_start + 7..].find('"').unwrap()
+ 7;
let title_value = &output[title_start..title_start + title_end + 1];
assert!(
!title_value.contains("fe-b"),
"Hydration markers leaked into attribute value: {title_value}"
);
}
#[test]
fn test_hydration_nested_for_if_streams_full() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::component("hydratableComponent")],
},
);
fragments.insert(
"hydratableComponent".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::signal("title", false),
WebUIFragment::for_loop("category", "categories", "categoryTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"categoryTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<section"),
WebUIFragment::attribute("data-category", "category.name"),
WebUIFragment::plugin(1u32.to_le_bytes().to_vec()),
WebUIFragment::raw(">"),
WebUIFragment::signal("category.title", false),
WebUIFragment::if_cond(
ConditionExpr::compound(
ConditionExpr::identifier("category.hasItems"),
LogicalOperator::And,
ConditionExpr::identifier("category.alwaysTrue"),
),
"itemsTemplate",
),
WebUIFragment::raw("</section>"),
],
},
);
fragments.insert(
"itemsTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<ul>"),
WebUIFragment::for_loop("item", "category.items", "itemTemplate"),
WebUIFragment::raw("</ul>"),
],
},
);
fragments.insert(
"itemTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<li"),
WebUIFragment::attribute_template("id", "itemIdAttr"),
WebUIFragment::attribute("data-name", "item.name"),
WebUIFragment::plugin(2u32.to_le_bytes().to_vec()),
WebUIFragment::raw(">"),
WebUIFragment::signal("item.name", false),
WebUIFragment::if_cond(
ConditionExpr::identifier("item.special"),
"specialTemplate",
),
WebUIFragment::raw("</li>"),
],
},
);
fragments.insert(
"itemIdAttr".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("item-"),
WebUIFragment::signal("item.id", false),
],
},
);
fragments.insert(
"specialTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw(" ("),
WebUIFragment::signal("item.specialText", false),
WebUIFragment::raw(")"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"title": "My Store",
"categories": [
{
"name": "electronics",
"title": "Electronics",
"hasItems": true,
"alwaysTrue": true,
"items": [
{"id": "1", "name": "Laptop", "special": true, "specialText": "On Sale"},
{"id": "2", "name": "Phone", "special": false}
]
},
{"name": "books", "title": "Books", "hasItems": false},
{"name": "toys", "title": "Toys", "hasItems": true, "alwaysTrue": true, "items": []}
]
});
let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
let expected = "\
<div>\
<!--fe-b$$start$$0$$title$$fe-b-->My Store<!--fe-b$$end$$0$$title$$fe-b-->\
<!--fe-b$$start$$1$$categoryTemplate$$fe-b-->\
<!--fe-repeat$$start$$0$$fe-repeat-->\
<section data-category=\"electronics\" data-fe-b-0>\
<!--fe-b$$start$$1$$category.title$$fe-b-->Electronics<!--fe-b$$end$$1$$category.title$$fe-b-->\
<!--fe-b$$start$$2$$itemsTemplate$$fe-b-->\
<ul>\
<!--fe-b$$start$$0$$itemTemplate$$fe-b-->\
<!--fe-repeat$$start$$0$$fe-repeat-->\
<li id=\"item-1\" data-name=\"Laptop\" data-fe-c-0-2>\
<!--fe-b$$start$$2$$item.name$$fe-b-->Laptop<!--fe-b$$end$$2$$item.name$$fe-b-->\
<!--fe-b$$start$$3$$specialTemplate$$fe-b--> \
(<!--fe-b$$start$$0$$item.specialText$$fe-b-->On Sale<!--fe-b$$end$$0$$item.specialText$$fe-b-->)\
<!--fe-b$$end$$3$$specialTemplate$$fe-b-->\
</li>\
<!--fe-repeat$$end$$0$$fe-repeat-->\
<!--fe-repeat$$start$$1$$fe-repeat-->\
<li id=\"item-2\" data-name=\"Phone\" data-fe-c-0-2>\
<!--fe-b$$start$$2$$item.name$$fe-b-->Phone<!--fe-b$$end$$2$$item.name$$fe-b-->\
<!--fe-b$$start$$3$$specialTemplate$$fe-b-->\
<!--fe-b$$end$$3$$specialTemplate$$fe-b-->\
</li>\
<!--fe-repeat$$end$$1$$fe-repeat-->\
<!--fe-b$$end$$0$$itemTemplate$$fe-b-->\
</ul>\
<!--fe-b$$end$$2$$itemsTemplate$$fe-b-->\
</section>\
<!--fe-repeat$$end$$0$$fe-repeat-->\
<!--fe-repeat$$start$$1$$fe-repeat-->\
<section data-category=\"books\" data-fe-b-0>\
<!--fe-b$$start$$1$$category.title$$fe-b-->Books<!--fe-b$$end$$1$$category.title$$fe-b-->\
<!--fe-b$$start$$2$$itemsTemplate$$fe-b-->\
<!--fe-b$$end$$2$$itemsTemplate$$fe-b-->\
</section>\
<!--fe-repeat$$end$$1$$fe-repeat-->\
<!--fe-repeat$$start$$2$$fe-repeat-->\
<section data-category=\"toys\" data-fe-b-0>\
<!--fe-b$$start$$1$$category.title$$fe-b-->Toys<!--fe-b$$end$$1$$category.title$$fe-b-->\
<!--fe-b$$start$$2$$itemsTemplate$$fe-b-->\
<ul>\
<!--fe-b$$start$$0$$itemTemplate$$fe-b-->\
<!--fe-b$$end$$0$$itemTemplate$$fe-b-->\
</ul>\
<!--fe-b$$end$$2$$itemsTemplate$$fe-b-->\
</section>\
<!--fe-repeat$$end$$2$$fe-repeat-->\
<!--fe-b$$end$$1$$categoryTemplate$$fe-b-->\
</div>";
assert_eq!(output, expected);
}
#[test]
fn test_hydration_missing_signal_still_emits_markers() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::component("my-comp")],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("missing_field", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
assert!(
output.contains("<!--fe-b$$start$$0$$missing_field$$fe-b-->"),
"Expected binding start marker for missing signal, got: {output}"
);
assert!(
output.contains("<!--fe-b$$end$$0$$missing_field$$fe-b-->"),
"Expected binding end marker for missing signal, got: {output}"
);
assert!(output.contains(
"<!--fe-b$$start$$0$$missing_field$$fe-b--><!--fe-b$$end$$0$$missing_field$$fe-b-->"
));
}
#[test]
fn test_hydration_missing_for_collection_still_emits_markers() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::component("my-comp")],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<ul>"),
WebUIFragment::for_loop("item", "missing_items", "loop-body"),
WebUIFragment::raw("</ul>"),
],
},
);
fragments.insert(
"loop-body".to_string(),
FragmentList {
fragments: vec![WebUIFragment::signal("item", false)],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
assert!(
output.contains("<!--fe-b$$start$$0$$loop-body$$fe-b-->"),
"Expected binding start marker for missing collection, got: {output}"
);
assert!(
output.contains("<!--fe-b$$end$$0$$loop-body$$fe-b-->"),
"Expected binding end marker for missing collection, got: {output}"
);
}
#[test]
fn test_hydration_empty_string_signal_still_emits_markers() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::component("my-comp")],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("name", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"name": ""});
let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
assert!(
output.contains("<!--fe-b$$start$$0$$name$$fe-b-->"),
"Expected binding start marker for empty string signal, got: {output}"
);
assert!(
output.contains("<!--fe-b$$end$$0$$name$$fe-b-->"),
"Expected binding end marker for empty string signal, got: {output}"
);
assert!(output.contains("<!--fe-b$$start$$0$$name$$fe-b--><!--fe-b$$end$$0$$name$$fe-b-->"));
}
#[test]
fn test_hydration_empty_collection_still_emits_markers() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::component("my-comp")],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<ul>"),
WebUIFragment::for_loop("item", "items", "loop-body"),
WebUIFragment::raw("</ul>"),
],
},
);
fragments.insert(
"loop-body".to_string(),
FragmentList {
fragments: vec![WebUIFragment::signal("item", false)],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"items": []});
let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
assert!(
output.contains("<!--fe-b$$start$$0$$loop-body$$fe-b-->"),
"Expected binding start marker for empty collection, got: {output}"
);
assert!(
output.contains("<!--fe-b$$end$$0$$loop-body$$fe-b-->"),
"Expected binding end marker for empty collection, got: {output}"
);
}
#[test]
fn test_for_if_hooks_delegate_to_binding_hooks() {
{
let mut plugin_for = FastHydrationPlugin::new();
plugin_for.push_scope();
let mut writer_for = TestWriter::new();
plugin_for.on_for_start("items", &mut writer_for).unwrap();
plugin_for.on_for_end("items", &mut writer_for).unwrap();
let mut plugin_bind = FastHydrationPlugin::new();
plugin_bind.push_scope();
let mut writer_bind = TestWriter::new();
plugin_bind
.on_binding_start("items", &mut writer_bind)
.unwrap();
plugin_bind
.on_binding_end("items", &mut writer_bind)
.unwrap();
assert_eq!(
writer_for.output, writer_bind.output,
"on_for_* should delegate to on_binding_*"
);
}
{
let mut plugin_if = FastHydrationPlugin::new();
plugin_if.push_scope();
let mut writer_if = TestWriter::new();
plugin_if.on_if_start("visible", &mut writer_if).unwrap();
plugin_if.on_if_end("visible", &mut writer_if).unwrap();
let mut plugin_bind = FastHydrationPlugin::new();
plugin_bind.push_scope();
let mut writer_bind = TestWriter::new();
plugin_bind
.on_binding_start("visible", &mut writer_bind)
.unwrap();
plugin_bind
.on_binding_end("visible", &mut writer_bind)
.unwrap();
assert_eq!(
writer_if.output, writer_bind.output,
"on_if_* should delegate to on_binding_*"
);
}
}
}