1use super::{BootstrapExtensionContext, HandlerPlugin};
13use crate::{ResponseWriter, Result};
14use std::collections::HashSet;
15use webui_protocol::WebUIProtocol;
16
17const REPEAT_START: &str = "<!--wr-->";
18const REPEAT_END: &str = "<!--/wr-->";
19const REPEAT_ITEM: &str = "<!--wi-->";
20const COND_START: &str = "<!--wc-->";
21const COND_END: &str = "<!--/wc-->";
22
23pub struct WebUIHydrationPlugin;
29
30impl WebUIHydrationPlugin {
31 #[must_use]
33 pub fn new() -> Self {
34 Self
35 }
36}
37
38impl Default for WebUIHydrationPlugin {
39 fn default() -> Self {
40 Self::new()
41 }
42}
43
44impl HandlerPlugin for WebUIHydrationPlugin {
45 fn push_scope(&mut self) {}
46
47 fn pop_scope(&mut self) {}
48
49 fn on_binding_start(&mut self, _name: &str, _writer: &mut dyn ResponseWriter) -> Result<()> {
50 Ok(())
51 }
52
53 fn on_binding_end(&mut self, _name: &str, _writer: &mut dyn ResponseWriter) -> Result<()> {
54 Ok(())
55 }
56
57 fn on_for_start(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
58 writer.write(REPEAT_START)
59 }
60
61 fn on_for_end(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
62 writer.write(REPEAT_END)
63 }
64
65 fn on_if_start(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
66 writer.write(COND_START)
67 }
68
69 fn on_if_end(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
70 writer.write(COND_END)
71 }
72
73 fn on_repeat_item_start(
74 &mut self,
75 _index: usize,
76 writer: &mut dyn ResponseWriter,
77 ) -> Result<()> {
78 writer.write(REPEAT_ITEM)
79 }
80
81 fn on_repeat_item_end(
83 &mut self,
84 _index: usize,
85 _writer: &mut dyn ResponseWriter,
86 ) -> Result<()> {
87 Ok(())
88 }
89
90 fn on_element_data(&mut self, _data: &[u8], _writer: &mut dyn ResponseWriter) -> Result<()> {
91 Ok(())
92 }
93
94 fn emit_templates(
101 &self,
102 protocol: &WebUIProtocol,
103 components: &HashSet<String>,
104 nonce: Option<&str>,
105 writer: &mut dyn ResponseWriter,
106 ) -> Result<()> {
107 let mut templates: Vec<&str> = Vec::with_capacity(components.len());
108
109 for name in components {
110 if let Some(template) = protocol
111 .components
112 .get(name)
113 .map(|component| component.template.as_str())
114 .filter(|t| !t.is_empty())
115 {
116 templates.push(template);
117 }
118 }
119
120 if templates.is_empty() {
121 return Ok(());
122 }
123
124 if let Some(nonce) = nonce {
125 writer.write("<script nonce=\"")?;
126 writer.write(nonce)?;
127 writer.write("\">\n")?;
128 } else {
129 writer.write("<script>\n")?;
130 }
131 for tmpl in &templates {
132 writer.write(tmpl)?;
133 }
134 writer.write("</script>\n")?;
135
136 Ok(())
137 }
138
139 fn collect_template_payloads<'a>(
141 &self,
142 protocol: &'a WebUIProtocol,
143 components: &HashSet<String>,
144 ) -> Option<Vec<super::WebUiTemplatePayload<'a>>> {
145 let mut templates: Vec<super::WebUiTemplatePayload<'a>> =
146 Vec::with_capacity(components.len());
147 for name in components {
148 if let Some((tag_name, component)) = protocol.components.get_key_value(name) {
149 if !component.template_json.is_empty() {
150 templates.push(super::WebUiTemplatePayload {
151 tag_name: tag_name.as_str(),
152 template_json: component.template_json.as_str(),
153 template_functions: component.template_functions.as_str(),
154 });
155 }
156 }
157 }
158 if templates.is_empty() {
159 None
160 } else {
161 Some(templates)
162 }
163 }
164
165 fn emit_bootstrap_extension(
166 &self,
167 context: BootstrapExtensionContext<'_>,
168 writer: &mut dyn ResponseWriter,
169 ) -> Result<()> {
170 let has_functions = context
171 .payloads
172 .iter()
173 .any(|payload| !payload.template_functions.is_empty());
174 if !has_functions {
175 return Ok(());
176 }
177
178 if let Some(nonce) = context.nonce {
179 writer.write("<script nonce=\"")?;
180 writer.write(nonce)?;
181 writer.write("\">")?;
182 } else {
183 writer.write("<script>")?;
184 }
185 writer.write("(function(){var w=window.__webui||(window.__webui={});")?;
186 writer.write("var f=w.templateFns||(w.templateFns={});")?;
187 for payload in context.payloads {
188 if payload.template_functions.is_empty() {
189 continue;
190 }
191 writer.write("f[")?;
192 crate::write_script_safe_json(writer, payload.tag_name)?;
193 writer.write("]=")?;
194 writer.write(payload.template_functions)?;
195 writer.write(";")?;
196 }
197 writer.write("})();</script>\n")
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 struct TestWriter {
206 output: String,
207 }
208
209 impl TestWriter {
210 fn new() -> Self {
211 Self {
212 output: String::new(),
213 }
214 }
215 }
216
217 impl ResponseWriter for TestWriter {
218 fn write(&mut self, content: &str) -> Result<()> {
219 self.output.push_str(content);
220 Ok(())
221 }
222 fn end(&mut self) -> Result<()> {
223 Ok(())
224 }
225 }
226
227 #[test]
228 fn test_default_creates_instance() {
229 let _plugin = WebUIHydrationPlugin;
230 }
231
232 #[test]
233 fn test_signal_binding_emits_no_output() {
234 let mut plugin = WebUIHydrationPlugin::new();
235 let mut writer = TestWriter::new();
236 plugin.on_binding_start("x", &mut writer).unwrap();
237 plugin.on_binding_end("x", &mut writer).unwrap();
238 assert_eq!(
239 writer.output, "",
240 "signal binding hooks must not emit output"
241 );
242 }
243
244 #[test]
245 fn test_for_loop_emits_repeat_markers() {
246 let mut plugin = WebUIHydrationPlugin::new();
247 let mut writer = TestWriter::new();
248 plugin.on_for_start("items", &mut writer).unwrap();
249 plugin.on_repeat_item_start(0, &mut writer).unwrap();
250 plugin.push_scope();
251 writer.write("<div>A</div>").unwrap();
252 plugin.pop_scope();
253 plugin.on_repeat_item_end(0, &mut writer).unwrap();
254 plugin.on_repeat_item_start(1, &mut writer).unwrap();
255 plugin.push_scope();
256 writer.write("<div>B</div>").unwrap();
257 plugin.pop_scope();
258 plugin.on_repeat_item_end(1, &mut writer).unwrap();
259 plugin.on_for_end("items", &mut writer).unwrap();
260 assert_eq!(
261 writer.output,
262 "<!--wr--><!--wi--><div>A</div><!--wi--><div>B</div><!--/wr-->"
263 );
264 }
265
266 #[test]
267 fn test_empty_for_loop_emits_boundary_markers() {
268 let mut plugin = WebUIHydrationPlugin::new();
269 let mut writer = TestWriter::new();
270 plugin.on_for_start("items", &mut writer).unwrap();
271 plugin.on_for_end("items", &mut writer).unwrap();
272 assert_eq!(writer.output, "<!--wr--><!--/wr-->");
273 }
274
275 #[test]
276 fn test_if_true_emits_cond_markers() {
277 let mut plugin = WebUIHydrationPlugin::new();
278 let mut writer = TestWriter::new();
279 plugin.on_if_start("show", &mut writer).unwrap();
280 plugin.push_scope();
281 writer.write("<p>visible</p>").unwrap();
282 plugin.pop_scope();
283 plugin.on_if_end("show", &mut writer).unwrap();
284 assert_eq!(writer.output, "<!--wc--><p>visible</p><!--/wc-->");
285 }
286
287 #[test]
288 fn test_if_false_emits_empty_cond_markers() {
289 let mut plugin = WebUIHydrationPlugin::new();
290 let mut writer = TestWriter::new();
291 plugin.on_if_start("show", &mut writer).unwrap();
292 plugin.on_if_end("show", &mut writer).unwrap();
294 assert_eq!(writer.output, "<!--wc--><!--/wc-->");
295 }
296
297 #[test]
298 fn test_nested_repeat_inside_conditional() {
299 let mut plugin = WebUIHydrationPlugin::new();
300 let mut writer = TestWriter::new();
301 plugin.on_if_start("show", &mut writer).unwrap();
302 plugin.push_scope();
303 plugin.on_for_start("items", &mut writer).unwrap();
304 plugin.on_repeat_item_start(0, &mut writer).unwrap();
305 plugin.push_scope();
306 writer.write("<li>X</li>").unwrap();
307 plugin.pop_scope();
308 plugin.on_repeat_item_end(0, &mut writer).unwrap();
309 plugin.on_for_end("items", &mut writer).unwrap();
310 plugin.pop_scope();
311 plugin.on_if_end("show", &mut writer).unwrap();
312 assert_eq!(
313 writer.output,
314 "<!--wc--><!--wr--><!--wi--><li>X</li><!--/wr--><!--/wc-->"
315 );
316 }
317
318 #[test]
319 fn test_element_data_emits_no_output() {
320 let mut plugin = WebUIHydrationPlugin::new();
321 let mut writer = TestWriter::new();
322 plugin.push_scope();
323 let data = 3u32.to_le_bytes();
324 plugin.on_element_data(&data, &mut writer).unwrap();
325 assert_eq!(writer.output, "", "element data must not emit output");
326 }
327
328 #[test]
329 fn test_scope_push_pop_is_noop() {
330 let mut plugin = WebUIHydrationPlugin::new();
331 plugin.push_scope();
332 plugin.push_scope();
333 plugin.pop_scope();
334 plugin.pop_scope();
335 }
336
337 #[test]
338 fn test_route_component_state_emits_no_output() {
339 let plugin = WebUIHydrationPlugin::new();
340 let mut writer = TestWriter::new();
341 let state = serde_json::json!({
342 "title": "Hello",
343 "cartOpen": true,
344 "items": [{"name": "A&B"}],
345 });
346 plugin
347 .write_route_component_state(&state, &mut writer)
348 .unwrap();
349 assert_eq!(
350 writer.output, "",
351 "route component state must not emit output"
352 );
353 }
354
355 #[test]
356 fn test_full_lifecycle_with_markers() {
357 let mut plugin = WebUIHydrationPlugin::new();
358 let mut writer = TestWriter::new();
359
360 plugin.push_scope();
362 plugin.on_binding_start("a", &mut writer).unwrap();
364 writer.write("hello").unwrap();
365 plugin.on_binding_end("a", &mut writer).unwrap();
366 plugin.on_for_start("list", &mut writer).unwrap();
368 plugin.on_repeat_item_start(0, &mut writer).unwrap();
369 plugin.push_scope();
370 writer.write("<x-item>1</x-item>").unwrap();
371 plugin.pop_scope();
372 plugin.on_repeat_item_end(0, &mut writer).unwrap();
373 plugin.on_for_end("list", &mut writer).unwrap();
374 plugin.on_if_start("flag", &mut writer).unwrap();
376 plugin.push_scope();
377 writer.write("<p>yes</p>").unwrap();
378 plugin.pop_scope();
379 plugin.on_if_end("flag", &mut writer).unwrap();
380 plugin.pop_scope();
381
382 assert_eq!(
383 writer.output,
384 "hello<!--wr--><!--wi--><x-item>1</x-item><!--/wr--><!--wc--><p>yes</p><!--/wc-->"
385 );
386 }
387
388 fn iife_template(tag: &str, body: &str) -> String {
389 format!("(function(){{var w=(window.__webui||(window.__webui={{}})).templates||(window.__webui.templates={{}});w['{tag}']={{{body}}}}})();\n")
390 }
391
392 #[test]
393 fn test_emit_templates_only_emits_provided_components() {
394 let mut writer = TestWriter::new();
395
396 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
397 protocol
398 .components
399 .entry("comp-a".to_string())
400 .or_default()
401 .template = iife_template("comp-a", "h:\"a\"");
402 protocol
403 .components
404 .entry("comp-b".to_string())
405 .or_default()
406 .template = iife_template("comp-b", "h:\"b\"");
407 protocol
408 .components
409 .entry("comp-c".to_string())
410 .or_default()
411 .template = iife_template("comp-c", "h:\"c\"");
412
413 let mut components = std::collections::HashSet::new();
414 components.insert("comp-a".to_string());
415
416 let plugin = WebUIHydrationPlugin::new();
417 plugin
418 .emit_templates(&protocol, &components, None, &mut writer)
419 .unwrap();
420
421 assert!(
422 writer.output.contains("comp-a"),
423 "rendered component should be emitted: {}",
424 writer.output
425 );
426 assert!(
427 !writer.output.contains("comp-b"),
428 "non-rendered component should NOT be emitted: {}",
429 writer.output
430 );
431 assert!(
432 !writer.output.contains("comp-c"),
433 "non-rendered component should NOT be emitted: {}",
434 writer.output
435 );
436 }
437
438 #[test]
439 fn test_emit_templates_empty_set() {
440 let mut writer = TestWriter::new();
441 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
442 protocol
443 .components
444 .entry("comp-a".to_string())
445 .or_default()
446 .template = iife_template("comp-a", "h:\"a\"");
447 let components = std::collections::HashSet::new();
448 let plugin = WebUIHydrationPlugin::new();
449 plugin
450 .emit_templates(&protocol, &components, None, &mut writer)
451 .unwrap();
452 assert_eq!(writer.output, "", "empty set should emit nothing");
453 }
454
455 #[test]
456 fn test_on_render_complete_consolidates_into_single_script() {
457 let mut writer = TestWriter::new();
458
459 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
460 protocol
461 .components
462 .entry("comp-a".to_string())
463 .or_default()
464 .template = iife_template("comp-a", "h:\"a\"");
465 protocol
466 .components
467 .entry("comp-b".to_string())
468 .or_default()
469 .template = iife_template("comp-b", "h:\"b\"");
470
471 let mut rendered = std::collections::HashSet::new();
472 rendered.insert("comp-a".to_string());
473 rendered.insert("comp-b".to_string());
474
475 let plugin = WebUIHydrationPlugin::new();
476 plugin
477 .emit_templates(&protocol, &rendered, None, &mut writer)
478 .unwrap();
479
480 let script_count = writer.output.matches("<script").count();
481 assert_eq!(
482 script_count, 1,
483 "should emit exactly one <script> tag, got {script_count}: {}",
484 writer.output
485 );
486 assert!(writer.output.contains("comp-a"));
487 assert!(writer.output.contains("comp-b"));
488 }
489
490 #[test]
491 fn test_on_render_complete_skips_empty_template() {
492 let mut writer = TestWriter::new();
493 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
494 protocol
495 .components
496 .entry("comp-a".to_string())
497 .or_default()
498 .template = String::new();
499 let mut rendered = std::collections::HashSet::new();
500 rendered.insert("comp-a".to_string());
501 let plugin = WebUIHydrationPlugin::new();
502 plugin
503 .emit_templates(&protocol, &rendered, None, &mut writer)
504 .unwrap();
505 assert_eq!(writer.output, "", "empty template should not be emitted");
506 }
507
508 #[test]
509 fn test_on_render_complete_unknown_component() {
510 let mut writer = TestWriter::new();
511 let protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
512 let mut rendered = std::collections::HashSet::new();
513 rendered.insert("nonexistent-comp".to_string());
514 let plugin = WebUIHydrationPlugin::new();
515 plugin
516 .emit_templates(&protocol, &rendered, None, &mut writer)
517 .unwrap();
518 assert_eq!(
519 writer.output, "",
520 "unknown component should not cause error"
521 );
522 }
523
524 use std::collections::HashMap;
527 use webui_protocol::{ConditionExpr, FragmentList, WebUIFragment, WebUIProtocol};
528 use webui_test_utils::test_json;
529
530 use crate::{RenderOptions, WebUIHandler};
531
532 fn render_with_webui_plugin(protocol: &WebUIProtocol, state: &serde_json::Value) -> String {
533 let mut writer = TestWriter::new();
534 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
535 handler
536 .handle(
537 protocol,
538 state,
539 &RenderOptions::new("index.html", "/"),
540 &mut writer,
541 )
542 .unwrap();
543 writer.output
544 }
545
546 #[test]
547 fn test_handler_emits_hydration_markers_for_loop_and_if() {
548 let mut fragments = HashMap::new();
550 fragments.insert(
551 "index.html".to_string(),
552 FragmentList {
553 fragments: vec![
554 WebUIFragment::for_loop("item", "items", "for-body"),
555 WebUIFragment::if_cond(ConditionExpr::identifier("show"), "if-body"),
556 ],
557 },
558 );
559 fragments.insert(
560 "for-body".to_string(),
561 FragmentList {
562 fragments: vec![WebUIFragment::signal("item", false)],
563 },
564 );
565 fragments.insert(
566 "if-body".to_string(),
567 FragmentList {
568 fragments: vec![WebUIFragment::raw("<span>yes</span>")],
569 },
570 );
571 let protocol = WebUIProtocol::new(fragments);
572 let state = test_json!({"items": ["a", "b"], "show": true});
573 let output = render_with_webui_plugin(&protocol, &state);
574
575 assert!(
577 output.contains("<!--wr-->"),
578 "Expected repeat-start marker, got: {output}"
579 );
580 assert!(
581 output.contains("<!--/wr-->"),
582 "Expected repeat-end marker, got: {output}"
583 );
584 assert!(
585 output.contains("<!--wi-->"),
586 "Expected repeat-item marker, got: {output}"
587 );
588 assert_eq!(
590 output.matches("<!--wi-->").count(),
591 2,
592 "Expected 2 repeat-item markers, got: {output}"
593 );
594
595 assert!(
597 output.contains("<!--wc-->"),
598 "Expected cond-start marker, got: {output}"
599 );
600 assert!(
601 output.contains("<!--/wc-->"),
602 "Expected cond-end marker, got: {output}"
603 );
604
605 assert!(
607 output.contains("a"),
608 "Expected for-loop items in output, got: {output}"
609 );
610 assert!(
611 output.contains("b"),
612 "Expected for-loop items in output, got: {output}"
613 );
614 assert!(
615 output.contains("<span>yes</span>"),
616 "Expected if-condition body, got: {output}"
617 );
618 }
619}