use super::{BootstrapExtensionContext, HandlerPlugin};
use crate::{ResponseWriter, Result};
use webui_protocol::WebUIProtocol;
const REPEAT_START: &str = "<!--wr-->";
const REPEAT_END: &str = "<!--/wr-->";
const REPEAT_ITEM: &str = "<!--wi-->";
const COND_START: &str = "<!--wc-->";
const COND_END: &str = "<!--/wc-->";
const RAW_START_PREFIX: &str = "<!--w";
const RAW_END_PREFIX: &str = "<!--/w";
const MARKER_SUFFIX: &str = "-->";
pub struct WebUIHydrationPlugin {
raw_index: usize,
raw_marker: String,
}
impl WebUIHydrationPlugin {
#[must_use]
pub fn new() -> Self {
Self {
raw_index: 0,
raw_marker: String::new(),
}
}
}
fn write_raw_marker(
marker: &mut String,
writer: &mut dyn ResponseWriter,
index: usize,
closing: bool,
) -> Result<()> {
let mut reversed_digits = [0_u8; 20];
let mut digit_count = 0;
let mut remaining = index;
loop {
reversed_digits[digit_count] = b"0123456789"[remaining % 10];
digit_count += 1;
remaining /= 10;
if remaining == 0 {
break;
}
}
marker.clear();
if marker.capacity() == 0 {
marker.reserve(32);
}
marker.push_str(if closing {
RAW_END_PREFIX
} else {
RAW_START_PREFIX
});
for index in (0..digit_count).rev() {
marker.push(char::from(reversed_digits[index]));
}
marker.push_str(MARKER_SUFFIX);
writer.write(marker)
}
impl Default for WebUIHydrationPlugin {
fn default() -> Self {
Self::new()
}
}
impl HandlerPlugin for WebUIHydrationPlugin {
fn push_scope(&mut self) {}
fn pop_scope(&mut self) {}
fn on_binding_start(
&mut self,
_name: &str,
raw: bool,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
if raw {
write_raw_marker(&mut self.raw_marker, writer, self.raw_index, false)
} else {
Ok(())
}
}
fn on_binding_end(
&mut self,
_name: &str,
raw: bool,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
if raw {
write_raw_marker(&mut self.raw_marker, writer, self.raw_index, true)?;
self.raw_index += 1;
}
Ok(())
}
fn on_for_start(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
writer.write(REPEAT_START)
}
fn on_for_end(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
writer.write(REPEAT_END)
}
fn on_if_start(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
writer.write(COND_START)
}
fn on_if_end(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
writer.write(COND_END)
}
fn on_repeat_item_start(
&mut self,
_index: usize,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
writer.write(REPEAT_ITEM)
}
fn on_repeat_item_end(
&mut self,
_index: usize,
_writer: &mut dyn ResponseWriter,
) -> Result<()> {
Ok(())
}
fn on_element_data(&mut self, _data: &[u8], _writer: &mut dyn ResponseWriter) -> Result<()> {
Ok(())
}
fn emit_templates(
&self,
protocol: &WebUIProtocol,
components: &[String],
nonce: Option<&str>,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
webui_emit_templates(
protocol,
components.iter().map(String::as_str),
nonce,
writer,
)
}
fn emit_templates_slice(
&self,
protocol: &WebUIProtocol,
tags: &[&str],
nonce: Option<&str>,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
webui_emit_templates(protocol, tags.iter().copied(), nonce, writer)
}
fn collect_template_payloads<'a>(
&self,
protocol: &'a WebUIProtocol,
components: &[String],
) -> Option<Vec<super::WebUiTemplatePayload<'a>>> {
webui_collect_payloads(protocol, components.iter().map(String::as_str))
}
fn collect_template_payloads_slice<'a>(
&self,
protocol: &'a WebUIProtocol,
tags: &[&str],
) -> Option<Vec<super::WebUiTemplatePayload<'a>>> {
webui_collect_payloads(protocol, tags.iter().copied())
}
fn emit_bootstrap_extension(
&self,
context: BootstrapExtensionContext<'_>,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
webui_emit_bootstrap_fns(context.payloads, context.nonce, writer)
}
fn emit_bootstrap_extension_payloads(
&self,
payloads: &[super::WebUiTemplatePayload<'_>],
nonce: Option<&str>,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
webui_emit_bootstrap_fns(payloads, nonce, writer)
}
}
fn webui_emit_templates<'b>(
protocol: &WebUIProtocol,
tags: impl Iterator<Item = &'b str>,
nonce: Option<&str>,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
let mut templates: Vec<&str> = Vec::new();
for name in tags {
if let Some(template) = protocol
.components
.get(name)
.map(|component| component.template.as_str())
.filter(|t| !t.is_empty())
{
templates.push(template);
}
}
if templates.is_empty() {
return Ok(());
}
if let Some(nonce) = nonce {
writer.write("<script nonce=\"")?;
writer.write(nonce)?;
writer.write("\">\n")?;
} else {
writer.write("<script>\n")?;
}
for tmpl in &templates {
writer.write(tmpl)?;
}
writer.write("</script>\n")?;
Ok(())
}
fn webui_collect_payloads<'a, 'b>(
protocol: &'a WebUIProtocol,
tags: impl Iterator<Item = &'b str>,
) -> Option<Vec<super::WebUiTemplatePayload<'a>>> {
let mut templates: Vec<super::WebUiTemplatePayload<'a>> = Vec::new();
for name in tags {
if let Some((tag_name, component)) = protocol.components.get_key_value(name) {
if !component.template_json.is_empty() {
templates.push(super::WebUiTemplatePayload {
tag_name: tag_name.as_str(),
template_json: component.template_json.as_str(),
template_functions: component.template_functions.as_str(),
});
}
}
}
if templates.is_empty() {
None
} else {
Some(templates)
}
}
fn webui_emit_bootstrap_fns(
payloads: &[super::WebUiTemplatePayload<'_>],
nonce: Option<&str>,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
let has_functions = payloads
.iter()
.any(|payload| !payload.template_functions.is_empty());
if !has_functions {
return Ok(());
}
if let Some(nonce) = nonce {
writer.write("<script nonce=\"")?;
writer.write(nonce)?;
writer.write("\">")?;
} else {
writer.write("<script>")?;
}
writer.write("(function(){var w=window.__webui||(window.__webui={});")?;
writer.write("var f=w.templateFns||(w.templateFns={});")?;
let mut json_scratch = Vec::new();
for payload in payloads {
if payload.template_functions.is_empty() {
continue;
}
writer.write("f[")?;
crate::write_script_safe_json(writer, &mut json_scratch, payload.tag_name)?;
writer.write("]=")?;
writer.write(payload.template_functions)?;
writer.write(";")?;
}
writer.write("})();</script>\n")
}
#[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_default_creates_instance() {
let _plugin = WebUIHydrationPlugin::default();
}
#[test]
fn test_signal_binding_emits_no_output() {
let mut plugin = WebUIHydrationPlugin::new();
let mut writer = TestWriter::new();
plugin.on_binding_start("x", false, &mut writer).unwrap();
plugin.on_binding_end("x", false, &mut writer).unwrap();
assert_eq!(
writer.output, "",
"signal binding hooks must not emit output"
);
}
#[test]
fn test_raw_signal_binding_emits_range_markers() {
let mut plugin = WebUIHydrationPlugin::new();
let mut writer = TestWriter::new();
plugin.on_binding_start("html", true, &mut writer).unwrap();
writer.write("<b>trusted</b>").unwrap();
plugin.on_binding_end("html", true, &mut writer).unwrap();
assert_eq!(writer.output, "<!--w0--><b>trusted</b><!--/w0-->");
}
#[test]
fn test_for_loop_emits_repeat_markers() {
let mut plugin = WebUIHydrationPlugin::new();
let mut writer = TestWriter::new();
plugin.on_for_start("items", &mut writer).unwrap();
plugin.on_repeat_item_start(0, &mut writer).unwrap();
plugin.push_scope();
writer.write("<div>A</div>").unwrap();
plugin.pop_scope();
plugin.on_repeat_item_end(0, &mut writer).unwrap();
plugin.on_repeat_item_start(1, &mut writer).unwrap();
plugin.push_scope();
writer.write("<div>B</div>").unwrap();
plugin.pop_scope();
plugin.on_repeat_item_end(1, &mut writer).unwrap();
plugin.on_for_end("items", &mut writer).unwrap();
assert_eq!(
writer.output,
"<!--wr--><!--wi--><div>A</div><!--wi--><div>B</div><!--/wr-->"
);
}
#[test]
fn test_empty_for_loop_emits_boundary_markers() {
let mut plugin = WebUIHydrationPlugin::new();
let mut writer = TestWriter::new();
plugin.on_for_start("items", &mut writer).unwrap();
plugin.on_for_end("items", &mut writer).unwrap();
assert_eq!(writer.output, "<!--wr--><!--/wr-->");
}
#[test]
fn test_if_true_emits_cond_markers() {
let mut plugin = WebUIHydrationPlugin::new();
let mut writer = TestWriter::new();
plugin.on_if_start("show", &mut writer).unwrap();
plugin.push_scope();
writer.write("<p>visible</p>").unwrap();
plugin.pop_scope();
plugin.on_if_end("show", &mut writer).unwrap();
assert_eq!(writer.output, "<!--wc--><p>visible</p><!--/wc-->");
}
#[test]
fn test_if_false_emits_empty_cond_markers() {
let mut plugin = WebUIHydrationPlugin::new();
let mut writer = TestWriter::new();
plugin.on_if_start("show", &mut writer).unwrap();
plugin.on_if_end("show", &mut writer).unwrap();
assert_eq!(writer.output, "<!--wc--><!--/wc-->");
}
#[test]
fn test_nested_repeat_inside_conditional() {
let mut plugin = WebUIHydrationPlugin::new();
let mut writer = TestWriter::new();
plugin.on_if_start("show", &mut writer).unwrap();
plugin.push_scope();
plugin.on_for_start("items", &mut writer).unwrap();
plugin.on_repeat_item_start(0, &mut writer).unwrap();
plugin.push_scope();
writer.write("<li>X</li>").unwrap();
plugin.pop_scope();
plugin.on_repeat_item_end(0, &mut writer).unwrap();
plugin.on_for_end("items", &mut writer).unwrap();
plugin.pop_scope();
plugin.on_if_end("show", &mut writer).unwrap();
assert_eq!(
writer.output,
"<!--wc--><!--wr--><!--wi--><li>X</li><!--/wr--><!--/wc-->"
);
}
#[test]
fn test_element_data_emits_no_output() {
let mut plugin = WebUIHydrationPlugin::new();
let mut writer = TestWriter::new();
plugin.push_scope();
let data = 3u32.to_le_bytes();
plugin.on_element_data(&data, &mut writer).unwrap();
assert_eq!(writer.output, "", "element data must not emit output");
}
#[test]
fn test_scope_push_pop_is_noop() {
let mut plugin = WebUIHydrationPlugin::new();
plugin.push_scope();
plugin.push_scope();
plugin.pop_scope();
plugin.pop_scope();
}
#[test]
fn test_route_component_state_emits_no_output() {
let plugin = WebUIHydrationPlugin::new();
let mut writer = TestWriter::new();
let state = serde_json::json!({
"title": "Hello",
"cartOpen": true,
"items": [{"name": "A&B"}],
});
plugin
.write_route_component_state(&state, &mut writer)
.unwrap();
assert_eq!(
writer.output, "",
"route component state must not emit output"
);
}
#[test]
fn test_full_lifecycle_with_markers() {
let mut plugin = WebUIHydrationPlugin::new();
let mut writer = TestWriter::new();
plugin.push_scope();
plugin.on_binding_start("a", false, &mut writer).unwrap();
writer.write("hello").unwrap();
plugin.on_binding_end("a", false, &mut writer).unwrap();
plugin.on_for_start("list", &mut writer).unwrap();
plugin.on_repeat_item_start(0, &mut writer).unwrap();
plugin.push_scope();
writer.write("<x-item>1</x-item>").unwrap();
plugin.pop_scope();
plugin.on_repeat_item_end(0, &mut writer).unwrap();
plugin.on_for_end("list", &mut writer).unwrap();
plugin.on_if_start("flag", &mut writer).unwrap();
plugin.push_scope();
writer.write("<p>yes</p>").unwrap();
plugin.pop_scope();
plugin.on_if_end("flag", &mut writer).unwrap();
plugin.pop_scope();
assert_eq!(
writer.output,
"hello<!--wr--><!--wi--><x-item>1</x-item><!--/wr--><!--wc--><p>yes</p><!--/wc-->"
);
}
fn iife_template(tag: &str, body: &str) -> String {
format!("(function(){{var w=(window.__webui||(window.__webui={{}})).templates||(window.__webui.templates={{}});w['{tag}']={{{body}}}}})();\n")
}
#[test]
fn test_emit_templates_only_emits_provided_components() {
let mut writer = TestWriter::new();
let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
protocol
.components
.entry("comp-a".to_string())
.or_default()
.template = iife_template("comp-a", "h:\"a\"");
protocol
.components
.entry("comp-b".to_string())
.or_default()
.template = iife_template("comp-b", "h:\"b\"");
protocol
.components
.entry("comp-c".to_string())
.or_default()
.template = iife_template("comp-c", "h:\"c\"");
let components = vec!["comp-a".to_string()];
let plugin = WebUIHydrationPlugin::new();
plugin
.emit_templates(&protocol, &components, None, &mut writer)
.unwrap();
assert!(
writer.output.contains("comp-a"),
"rendered component should be emitted: {}",
writer.output
);
assert!(
!writer.output.contains("comp-b"),
"non-rendered component should NOT be emitted: {}",
writer.output
);
assert!(
!writer.output.contains("comp-c"),
"non-rendered component should NOT be emitted: {}",
writer.output
);
}
#[test]
fn test_emit_templates_empty_set() {
let mut writer = TestWriter::new();
let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
protocol
.components
.entry("comp-a".to_string())
.or_default()
.template = iife_template("comp-a", "h:\"a\"");
let components: Vec<String> = Vec::new();
let plugin = WebUIHydrationPlugin::new();
plugin
.emit_templates(&protocol, &components, None, &mut writer)
.unwrap();
assert_eq!(writer.output, "", "empty set should emit nothing");
}
#[test]
fn test_on_render_complete_consolidates_into_single_script() {
let mut writer = TestWriter::new();
let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
protocol
.components
.entry("comp-a".to_string())
.or_default()
.template = iife_template("comp-a", "h:\"a\"");
protocol
.components
.entry("comp-b".to_string())
.or_default()
.template = iife_template("comp-b", "h:\"b\"");
let rendered = vec!["comp-a".to_string(), "comp-b".to_string()];
let plugin = WebUIHydrationPlugin::new();
plugin
.emit_templates(&protocol, &rendered, None, &mut writer)
.unwrap();
let script_count = writer.output.matches("<script").count();
assert_eq!(
script_count, 1,
"should emit exactly one <script> tag, got {script_count}: {}",
writer.output
);
assert!(writer.output.contains("comp-a"));
assert!(writer.output.contains("comp-b"));
}
#[test]
fn test_on_render_complete_skips_empty_template() {
let mut writer = TestWriter::new();
let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
protocol
.components
.entry("comp-a".to_string())
.or_default()
.template = String::new();
let rendered = vec!["comp-a".to_string()];
let plugin = WebUIHydrationPlugin::new();
plugin
.emit_templates(&protocol, &rendered, None, &mut writer)
.unwrap();
assert_eq!(writer.output, "", "empty template should not be emitted");
}
#[test]
fn test_on_render_complete_unknown_component() {
let mut writer = TestWriter::new();
let protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
let rendered = vec!["nonexistent-comp".to_string()];
let plugin = WebUIHydrationPlugin::new();
plugin
.emit_templates(&protocol, &rendered, None, &mut writer)
.unwrap();
assert_eq!(
writer.output, "",
"unknown component should not cause error"
);
}
use std::collections::HashMap;
use webui_protocol::{ConditionExpr, FragmentList, WebUIFragment, WebUIProtocol};
use webui_test_utils::test_json;
use crate::{RenderOptions, WebUIHandler};
fn render_with_webui_plugin(protocol: &WebUIProtocol, state: &serde_json::Value) -> String {
let mut writer = TestWriter::new();
let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
handler
.handle(
protocol,
state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
writer.output
}
#[test]
fn test_handler_emits_hydration_markers_for_loop_and_if() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::for_loop("item", "items", "for-body"),
WebUIFragment::if_cond(ConditionExpr::identifier("show"), "if-body"),
WebUIFragment::signal("html", true),
],
contains_boundary: false,
},
);
fragments.insert(
"for-body".to_string(),
FragmentList {
fragments: vec![WebUIFragment::signal("item", false)],
contains_boundary: false,
},
);
fragments.insert(
"if-body".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<span>yes</span>")],
contains_boundary: false,
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"items": ["a", "b"], "show": true, "html": "<i>raw</i>"});
let output = render_with_webui_plugin(&protocol, &state);
assert!(
output.contains("<!--wr-->"),
"Expected repeat-start marker, got: {output}"
);
assert!(
output.contains("<!--/wr-->"),
"Expected repeat-end marker, got: {output}"
);
assert!(
output.contains("<!--wi-->"),
"Expected repeat-item marker, got: {output}"
);
assert_eq!(
output.matches("<!--wi-->").count(),
2,
"Expected 2 repeat-item markers, got: {output}"
);
assert!(
output.contains("<!--wc-->"),
"Expected cond-start marker, got: {output}"
);
assert!(
output.contains("<!--/wc-->"),
"Expected cond-end marker, got: {output}"
);
assert!(
output.contains("a"),
"Expected for-loop items in output, got: {output}"
);
assert!(
output.contains("b"),
"Expected for-loop items in output, got: {output}"
);
assert!(
output.contains("<span>yes</span>"),
"Expected if-condition body, got: {output}"
);
assert!(
output.contains("<!--w0--><i>raw</i><!--/w0-->"),
"Expected bounded raw HTML markers, got: {output}"
);
}
#[test]
fn test_handler_keeps_raw_text_signal_marker_free() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<style>:root{"),
WebUIFragment::raw_text_signal("tokens", true),
WebUIFragment::raw("}</style>"),
],
contains_boundary: false,
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"tokens": "--accent:red"});
let output = render_with_webui_plugin(&protocol, &state);
assert!(output.contains("<style>:root{--accent:red}</style>"));
assert!(!output.contains("<!--w0-->"));
assert!(!output.contains("<!--/w0-->"));
}
}