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 webui_emit_templates(
108 protocol,
109 components.iter().map(String::as_str),
110 nonce,
111 writer,
112 )
113 }
114
115 fn emit_templates_slice(
116 &self,
117 protocol: &WebUIProtocol,
118 tags: &[&str],
119 nonce: Option<&str>,
120 writer: &mut dyn ResponseWriter,
121 ) -> Result<()> {
122 webui_emit_templates(protocol, tags.iter().copied(), nonce, writer)
123 }
124
125 fn collect_template_payloads<'a>(
127 &self,
128 protocol: &'a WebUIProtocol,
129 components: &HashSet<String>,
130 ) -> Option<Vec<super::WebUiTemplatePayload<'a>>> {
131 webui_collect_payloads(protocol, components.iter().map(String::as_str))
132 }
133
134 fn collect_template_payloads_slice<'a>(
135 &self,
136 protocol: &'a WebUIProtocol,
137 tags: &[&str],
138 ) -> Option<Vec<super::WebUiTemplatePayload<'a>>> {
139 webui_collect_payloads(protocol, tags.iter().copied())
140 }
141
142 fn emit_bootstrap_extension(
143 &self,
144 context: BootstrapExtensionContext<'_>,
145 writer: &mut dyn ResponseWriter,
146 ) -> Result<()> {
147 webui_emit_bootstrap_fns(context.payloads, context.nonce, writer)
148 }
149
150 fn emit_bootstrap_extension_payloads(
151 &self,
152 payloads: &[super::WebUiTemplatePayload<'_>],
153 nonce: Option<&str>,
154 writer: &mut dyn ResponseWriter,
155 ) -> Result<()> {
156 webui_emit_bootstrap_fns(payloads, nonce, writer)
157 }
158}
159
160fn webui_emit_templates<'b>(
166 protocol: &WebUIProtocol,
167 tags: impl Iterator<Item = &'b str>,
168 nonce: Option<&str>,
169 writer: &mut dyn ResponseWriter,
170) -> Result<()> {
171 let mut templates: Vec<&str> = Vec::new();
172
173 for name in tags {
174 if let Some(template) = protocol
175 .components
176 .get(name)
177 .map(|component| component.template.as_str())
178 .filter(|t| !t.is_empty())
179 {
180 templates.push(template);
181 }
182 }
183
184 if templates.is_empty() {
185 return Ok(());
186 }
187
188 if let Some(nonce) = nonce {
189 writer.write("<script nonce=\"")?;
190 writer.write(nonce)?;
191 writer.write("\">\n")?;
192 } else {
193 writer.write("<script>\n")?;
194 }
195 for tmpl in &templates {
196 writer.write(tmpl)?;
197 }
198 writer.write("</script>\n")?;
199
200 Ok(())
201}
202
203fn webui_collect_payloads<'a, 'b>(
208 protocol: &'a WebUIProtocol,
209 tags: impl Iterator<Item = &'b str>,
210) -> Option<Vec<super::WebUiTemplatePayload<'a>>> {
211 let mut templates: Vec<super::WebUiTemplatePayload<'a>> = Vec::new();
212 for name in tags {
213 if let Some((tag_name, component)) = protocol.components.get_key_value(name) {
214 if !component.template_json.is_empty() {
215 templates.push(super::WebUiTemplatePayload {
216 tag_name: tag_name.as_str(),
217 template_json: component.template_json.as_str(),
218 template_functions: component.template_functions.as_str(),
219 });
220 }
221 }
222 }
223 if templates.is_empty() {
224 None
225 } else {
226 Some(templates)
227 }
228}
229
230fn webui_emit_bootstrap_fns(
234 payloads: &[super::WebUiTemplatePayload<'_>],
235 nonce: Option<&str>,
236 writer: &mut dyn ResponseWriter,
237) -> Result<()> {
238 let has_functions = payloads
239 .iter()
240 .any(|payload| !payload.template_functions.is_empty());
241 if !has_functions {
242 return Ok(());
243 }
244
245 if let Some(nonce) = nonce {
246 writer.write("<script nonce=\"")?;
247 writer.write(nonce)?;
248 writer.write("\">")?;
249 } else {
250 writer.write("<script>")?;
251 }
252 writer.write("(function(){var w=window.__webui||(window.__webui={});")?;
253 writer.write("var f=w.templateFns||(w.templateFns={});")?;
254 let mut json_scratch = Vec::new();
257 for payload in payloads {
258 if payload.template_functions.is_empty() {
259 continue;
260 }
261 writer.write("f[")?;
262 crate::write_script_safe_json(writer, &mut json_scratch, payload.tag_name)?;
263 writer.write("]=")?;
264 writer.write(payload.template_functions)?;
265 writer.write(";")?;
266 }
267 writer.write("})();</script>\n")
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 struct TestWriter {
275 output: String,
276 }
277
278 impl TestWriter {
279 fn new() -> Self {
280 Self {
281 output: String::new(),
282 }
283 }
284 }
285
286 impl ResponseWriter for TestWriter {
287 fn write(&mut self, content: &str) -> Result<()> {
288 self.output.push_str(content);
289 Ok(())
290 }
291 fn end(&mut self) -> Result<()> {
292 Ok(())
293 }
294 }
295
296 #[test]
297 fn test_default_creates_instance() {
298 let _plugin = WebUIHydrationPlugin;
299 }
300
301 #[test]
302 fn test_signal_binding_emits_no_output() {
303 let mut plugin = WebUIHydrationPlugin::new();
304 let mut writer = TestWriter::new();
305 plugin.on_binding_start("x", &mut writer).unwrap();
306 plugin.on_binding_end("x", &mut writer).unwrap();
307 assert_eq!(
308 writer.output, "",
309 "signal binding hooks must not emit output"
310 );
311 }
312
313 #[test]
314 fn test_for_loop_emits_repeat_markers() {
315 let mut plugin = WebUIHydrationPlugin::new();
316 let mut writer = TestWriter::new();
317 plugin.on_for_start("items", &mut writer).unwrap();
318 plugin.on_repeat_item_start(0, &mut writer).unwrap();
319 plugin.push_scope();
320 writer.write("<div>A</div>").unwrap();
321 plugin.pop_scope();
322 plugin.on_repeat_item_end(0, &mut writer).unwrap();
323 plugin.on_repeat_item_start(1, &mut writer).unwrap();
324 plugin.push_scope();
325 writer.write("<div>B</div>").unwrap();
326 plugin.pop_scope();
327 plugin.on_repeat_item_end(1, &mut writer).unwrap();
328 plugin.on_for_end("items", &mut writer).unwrap();
329 assert_eq!(
330 writer.output,
331 "<!--wr--><!--wi--><div>A</div><!--wi--><div>B</div><!--/wr-->"
332 );
333 }
334
335 #[test]
336 fn test_empty_for_loop_emits_boundary_markers() {
337 let mut plugin = WebUIHydrationPlugin::new();
338 let mut writer = TestWriter::new();
339 plugin.on_for_start("items", &mut writer).unwrap();
340 plugin.on_for_end("items", &mut writer).unwrap();
341 assert_eq!(writer.output, "<!--wr--><!--/wr-->");
342 }
343
344 #[test]
345 fn test_if_true_emits_cond_markers() {
346 let mut plugin = WebUIHydrationPlugin::new();
347 let mut writer = TestWriter::new();
348 plugin.on_if_start("show", &mut writer).unwrap();
349 plugin.push_scope();
350 writer.write("<p>visible</p>").unwrap();
351 plugin.pop_scope();
352 plugin.on_if_end("show", &mut writer).unwrap();
353 assert_eq!(writer.output, "<!--wc--><p>visible</p><!--/wc-->");
354 }
355
356 #[test]
357 fn test_if_false_emits_empty_cond_markers() {
358 let mut plugin = WebUIHydrationPlugin::new();
359 let mut writer = TestWriter::new();
360 plugin.on_if_start("show", &mut writer).unwrap();
361 plugin.on_if_end("show", &mut writer).unwrap();
363 assert_eq!(writer.output, "<!--wc--><!--/wc-->");
364 }
365
366 #[test]
367 fn test_nested_repeat_inside_conditional() {
368 let mut plugin = WebUIHydrationPlugin::new();
369 let mut writer = TestWriter::new();
370 plugin.on_if_start("show", &mut writer).unwrap();
371 plugin.push_scope();
372 plugin.on_for_start("items", &mut writer).unwrap();
373 plugin.on_repeat_item_start(0, &mut writer).unwrap();
374 plugin.push_scope();
375 writer.write("<li>X</li>").unwrap();
376 plugin.pop_scope();
377 plugin.on_repeat_item_end(0, &mut writer).unwrap();
378 plugin.on_for_end("items", &mut writer).unwrap();
379 plugin.pop_scope();
380 plugin.on_if_end("show", &mut writer).unwrap();
381 assert_eq!(
382 writer.output,
383 "<!--wc--><!--wr--><!--wi--><li>X</li><!--/wr--><!--/wc-->"
384 );
385 }
386
387 #[test]
388 fn test_element_data_emits_no_output() {
389 let mut plugin = WebUIHydrationPlugin::new();
390 let mut writer = TestWriter::new();
391 plugin.push_scope();
392 let data = 3u32.to_le_bytes();
393 plugin.on_element_data(&data, &mut writer).unwrap();
394 assert_eq!(writer.output, "", "element data must not emit output");
395 }
396
397 #[test]
398 fn test_scope_push_pop_is_noop() {
399 let mut plugin = WebUIHydrationPlugin::new();
400 plugin.push_scope();
401 plugin.push_scope();
402 plugin.pop_scope();
403 plugin.pop_scope();
404 }
405
406 #[test]
407 fn test_route_component_state_emits_no_output() {
408 let plugin = WebUIHydrationPlugin::new();
409 let mut writer = TestWriter::new();
410 let state = serde_json::json!({
411 "title": "Hello",
412 "cartOpen": true,
413 "items": [{"name": "A&B"}],
414 });
415 plugin
416 .write_route_component_state(&state, &mut writer)
417 .unwrap();
418 assert_eq!(
419 writer.output, "",
420 "route component state must not emit output"
421 );
422 }
423
424 #[test]
425 fn test_full_lifecycle_with_markers() {
426 let mut plugin = WebUIHydrationPlugin::new();
427 let mut writer = TestWriter::new();
428
429 plugin.push_scope();
431 plugin.on_binding_start("a", &mut writer).unwrap();
433 writer.write("hello").unwrap();
434 plugin.on_binding_end("a", &mut writer).unwrap();
435 plugin.on_for_start("list", &mut writer).unwrap();
437 plugin.on_repeat_item_start(0, &mut writer).unwrap();
438 plugin.push_scope();
439 writer.write("<x-item>1</x-item>").unwrap();
440 plugin.pop_scope();
441 plugin.on_repeat_item_end(0, &mut writer).unwrap();
442 plugin.on_for_end("list", &mut writer).unwrap();
443 plugin.on_if_start("flag", &mut writer).unwrap();
445 plugin.push_scope();
446 writer.write("<p>yes</p>").unwrap();
447 plugin.pop_scope();
448 plugin.on_if_end("flag", &mut writer).unwrap();
449 plugin.pop_scope();
450
451 assert_eq!(
452 writer.output,
453 "hello<!--wr--><!--wi--><x-item>1</x-item><!--/wr--><!--wc--><p>yes</p><!--/wc-->"
454 );
455 }
456
457 fn iife_template(tag: &str, body: &str) -> String {
458 format!("(function(){{var w=(window.__webui||(window.__webui={{}})).templates||(window.__webui.templates={{}});w['{tag}']={{{body}}}}})();\n")
459 }
460
461 #[test]
462 fn test_emit_templates_only_emits_provided_components() {
463 let mut writer = TestWriter::new();
464
465 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
466 protocol
467 .components
468 .entry("comp-a".to_string())
469 .or_default()
470 .template = iife_template("comp-a", "h:\"a\"");
471 protocol
472 .components
473 .entry("comp-b".to_string())
474 .or_default()
475 .template = iife_template("comp-b", "h:\"b\"");
476 protocol
477 .components
478 .entry("comp-c".to_string())
479 .or_default()
480 .template = iife_template("comp-c", "h:\"c\"");
481
482 let mut components = std::collections::HashSet::new();
483 components.insert("comp-a".to_string());
484
485 let plugin = WebUIHydrationPlugin::new();
486 plugin
487 .emit_templates(&protocol, &components, None, &mut writer)
488 .unwrap();
489
490 assert!(
491 writer.output.contains("comp-a"),
492 "rendered component should be emitted: {}",
493 writer.output
494 );
495 assert!(
496 !writer.output.contains("comp-b"),
497 "non-rendered component should NOT be emitted: {}",
498 writer.output
499 );
500 assert!(
501 !writer.output.contains("comp-c"),
502 "non-rendered component should NOT be emitted: {}",
503 writer.output
504 );
505 }
506
507 #[test]
508 fn test_emit_templates_empty_set() {
509 let mut writer = TestWriter::new();
510 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
511 protocol
512 .components
513 .entry("comp-a".to_string())
514 .or_default()
515 .template = iife_template("comp-a", "h:\"a\"");
516 let components = std::collections::HashSet::new();
517 let plugin = WebUIHydrationPlugin::new();
518 plugin
519 .emit_templates(&protocol, &components, None, &mut writer)
520 .unwrap();
521 assert_eq!(writer.output, "", "empty set should emit nothing");
522 }
523
524 #[test]
525 fn test_on_render_complete_consolidates_into_single_script() {
526 let mut writer = TestWriter::new();
527
528 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
529 protocol
530 .components
531 .entry("comp-a".to_string())
532 .or_default()
533 .template = iife_template("comp-a", "h:\"a\"");
534 protocol
535 .components
536 .entry("comp-b".to_string())
537 .or_default()
538 .template = iife_template("comp-b", "h:\"b\"");
539
540 let mut rendered = std::collections::HashSet::new();
541 rendered.insert("comp-a".to_string());
542 rendered.insert("comp-b".to_string());
543
544 let plugin = WebUIHydrationPlugin::new();
545 plugin
546 .emit_templates(&protocol, &rendered, None, &mut writer)
547 .unwrap();
548
549 let script_count = writer.output.matches("<script").count();
550 assert_eq!(
551 script_count, 1,
552 "should emit exactly one <script> tag, got {script_count}: {}",
553 writer.output
554 );
555 assert!(writer.output.contains("comp-a"));
556 assert!(writer.output.contains("comp-b"));
557 }
558
559 #[test]
560 fn test_on_render_complete_skips_empty_template() {
561 let mut writer = TestWriter::new();
562 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
563 protocol
564 .components
565 .entry("comp-a".to_string())
566 .or_default()
567 .template = String::new();
568 let mut rendered = std::collections::HashSet::new();
569 rendered.insert("comp-a".to_string());
570 let plugin = WebUIHydrationPlugin::new();
571 plugin
572 .emit_templates(&protocol, &rendered, None, &mut writer)
573 .unwrap();
574 assert_eq!(writer.output, "", "empty template should not be emitted");
575 }
576
577 #[test]
578 fn test_on_render_complete_unknown_component() {
579 let mut writer = TestWriter::new();
580 let protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
581 let mut rendered = std::collections::HashSet::new();
582 rendered.insert("nonexistent-comp".to_string());
583 let plugin = WebUIHydrationPlugin::new();
584 plugin
585 .emit_templates(&protocol, &rendered, None, &mut writer)
586 .unwrap();
587 assert_eq!(
588 writer.output, "",
589 "unknown component should not cause error"
590 );
591 }
592
593 use std::collections::HashMap;
596 use webui_protocol::{ConditionExpr, FragmentList, WebUIFragment, WebUIProtocol};
597 use webui_test_utils::test_json;
598
599 use crate::{RenderOptions, WebUIHandler};
600
601 fn render_with_webui_plugin(protocol: &WebUIProtocol, state: &serde_json::Value) -> String {
602 let mut writer = TestWriter::new();
603 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
604 handler
605 .handle(
606 protocol,
607 state,
608 &RenderOptions::new("index.html", "/"),
609 &mut writer,
610 )
611 .unwrap();
612 writer.output
613 }
614
615 #[test]
616 fn test_handler_emits_hydration_markers_for_loop_and_if() {
617 let mut fragments = HashMap::new();
619 fragments.insert(
620 "index.html".to_string(),
621 FragmentList {
622 fragments: vec![
623 WebUIFragment::for_loop("item", "items", "for-body"),
624 WebUIFragment::if_cond(ConditionExpr::identifier("show"), "if-body"),
625 ],
626 },
627 );
628 fragments.insert(
629 "for-body".to_string(),
630 FragmentList {
631 fragments: vec![WebUIFragment::signal("item", false)],
632 },
633 );
634 fragments.insert(
635 "if-body".to_string(),
636 FragmentList {
637 fragments: vec![WebUIFragment::raw("<span>yes</span>")],
638 },
639 );
640 let protocol = WebUIProtocol::new(fragments);
641 let state = test_json!({"items": ["a", "b"], "show": true});
642 let output = render_with_webui_plugin(&protocol, &state);
643
644 assert!(
646 output.contains("<!--wr-->"),
647 "Expected repeat-start marker, got: {output}"
648 );
649 assert!(
650 output.contains("<!--/wr-->"),
651 "Expected repeat-end marker, got: {output}"
652 );
653 assert!(
654 output.contains("<!--wi-->"),
655 "Expected repeat-item marker, got: {output}"
656 );
657 assert_eq!(
659 output.matches("<!--wi-->").count(),
660 2,
661 "Expected 2 repeat-item markers, got: {output}"
662 );
663
664 assert!(
666 output.contains("<!--wc-->"),
667 "Expected cond-start marker, got: {output}"
668 );
669 assert!(
670 output.contains("<!--/wc-->"),
671 "Expected cond-end marker, got: {output}"
672 );
673
674 assert!(
676 output.contains("a"),
677 "Expected for-loop items in output, got: {output}"
678 );
679 assert!(
680 output.contains("b"),
681 "Expected for-loop items in output, got: {output}"
682 );
683 assert!(
684 output.contains("<span>yes</span>"),
685 "Expected if-condition body, got: {output}"
686 );
687 }
688}