1use super::{BootstrapExtensionContext, HandlerPlugin};
14use crate::{ResponseWriter, Result};
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-->";
22const RAW_START_PREFIX: &str = "<!--w";
23const RAW_END_PREFIX: &str = "<!--/w";
24const MARKER_SUFFIX: &str = "-->";
25
26pub struct WebUIHydrationPlugin {
32 raw_index: usize,
33 raw_marker: String,
34}
35
36impl WebUIHydrationPlugin {
37 #[must_use]
39 pub fn new() -> Self {
40 Self {
41 raw_index: 0,
42 raw_marker: String::new(),
43 }
44 }
45}
46
47fn write_raw_marker(
48 marker: &mut String,
49 writer: &mut dyn ResponseWriter,
50 index: usize,
51 closing: bool,
52) -> Result<()> {
53 let mut reversed_digits = [0_u8; 20];
54 let mut digit_count = 0;
55 let mut remaining = index;
56 loop {
57 reversed_digits[digit_count] = b"0123456789"[remaining % 10];
58 digit_count += 1;
59 remaining /= 10;
60 if remaining == 0 {
61 break;
62 }
63 }
64
65 marker.clear();
66 if marker.capacity() == 0 {
67 marker.reserve(32);
68 }
69 marker.push_str(if closing {
70 RAW_END_PREFIX
71 } else {
72 RAW_START_PREFIX
73 });
74 for index in (0..digit_count).rev() {
75 marker.push(char::from(reversed_digits[index]));
76 }
77 marker.push_str(MARKER_SUFFIX);
78 writer.write(marker)
79}
80
81impl Default for WebUIHydrationPlugin {
82 fn default() -> Self {
83 Self::new()
84 }
85}
86
87impl HandlerPlugin for WebUIHydrationPlugin {
88 fn push_scope(&mut self) {}
89
90 fn pop_scope(&mut self) {}
91
92 fn on_binding_start(
93 &mut self,
94 _name: &str,
95 raw: bool,
96 writer: &mut dyn ResponseWriter,
97 ) -> Result<()> {
98 if raw {
99 write_raw_marker(&mut self.raw_marker, writer, self.raw_index, false)
100 } else {
101 Ok(())
102 }
103 }
104
105 fn on_binding_end(
106 &mut self,
107 _name: &str,
108 raw: bool,
109 writer: &mut dyn ResponseWriter,
110 ) -> Result<()> {
111 if raw {
112 write_raw_marker(&mut self.raw_marker, writer, self.raw_index, true)?;
113 self.raw_index += 1;
114 }
115 Ok(())
116 }
117
118 fn on_for_start(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
119 writer.write(REPEAT_START)
120 }
121
122 fn on_for_end(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
123 writer.write(REPEAT_END)
124 }
125
126 fn on_if_start(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
127 writer.write(COND_START)
128 }
129
130 fn on_if_end(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
131 writer.write(COND_END)
132 }
133
134 fn on_repeat_item_start(
135 &mut self,
136 _index: usize,
137 writer: &mut dyn ResponseWriter,
138 ) -> Result<()> {
139 writer.write(REPEAT_ITEM)
140 }
141
142 fn on_repeat_item_end(
144 &mut self,
145 _index: usize,
146 _writer: &mut dyn ResponseWriter,
147 ) -> Result<()> {
148 Ok(())
149 }
150
151 fn on_element_data(&mut self, _data: &[u8], _writer: &mut dyn ResponseWriter) -> Result<()> {
152 Ok(())
153 }
154
155 fn emit_templates(
162 &self,
163 protocol: &WebUIProtocol,
164 components: &[String],
165 nonce: Option<&str>,
166 writer: &mut dyn ResponseWriter,
167 ) -> Result<()> {
168 webui_emit_templates(
169 protocol,
170 components.iter().map(String::as_str),
171 nonce,
172 writer,
173 )
174 }
175
176 fn emit_templates_slice(
177 &self,
178 protocol: &WebUIProtocol,
179 tags: &[&str],
180 nonce: Option<&str>,
181 writer: &mut dyn ResponseWriter,
182 ) -> Result<()> {
183 webui_emit_templates(protocol, tags.iter().copied(), nonce, writer)
184 }
185
186 fn collect_template_payloads<'a>(
188 &self,
189 protocol: &'a WebUIProtocol,
190 components: &[String],
191 ) -> Option<Vec<super::WebUiTemplatePayload<'a>>> {
192 webui_collect_payloads(protocol, components.iter().map(String::as_str))
193 }
194
195 fn collect_template_payloads_slice<'a>(
196 &self,
197 protocol: &'a WebUIProtocol,
198 tags: &[&str],
199 ) -> Option<Vec<super::WebUiTemplatePayload<'a>>> {
200 webui_collect_payloads(protocol, tags.iter().copied())
201 }
202
203 fn emit_bootstrap_extension(
204 &self,
205 context: BootstrapExtensionContext<'_>,
206 writer: &mut dyn ResponseWriter,
207 ) -> Result<()> {
208 webui_emit_bootstrap_fns(context.payloads, context.nonce, writer)
209 }
210
211 fn emit_bootstrap_extension_payloads(
212 &self,
213 payloads: &[super::WebUiTemplatePayload<'_>],
214 nonce: Option<&str>,
215 writer: &mut dyn ResponseWriter,
216 ) -> Result<()> {
217 webui_emit_bootstrap_fns(payloads, nonce, writer)
218 }
219}
220
221fn webui_emit_templates<'b>(
227 protocol: &WebUIProtocol,
228 tags: impl Iterator<Item = &'b str>,
229 nonce: Option<&str>,
230 writer: &mut dyn ResponseWriter,
231) -> Result<()> {
232 let mut templates: Vec<&str> = Vec::new();
233
234 for name in tags {
235 if let Some(template) = protocol
236 .components
237 .get(name)
238 .map(|component| component.template.as_str())
239 .filter(|t| !t.is_empty())
240 {
241 templates.push(template);
242 }
243 }
244
245 if templates.is_empty() {
246 return Ok(());
247 }
248
249 if let Some(nonce) = nonce {
250 writer.write("<script nonce=\"")?;
251 writer.write(nonce)?;
252 writer.write("\">\n")?;
253 } else {
254 writer.write("<script>\n")?;
255 }
256 for tmpl in &templates {
257 writer.write(tmpl)?;
258 }
259 writer.write("</script>\n")?;
260
261 Ok(())
262}
263
264fn webui_collect_payloads<'a, 'b>(
269 protocol: &'a WebUIProtocol,
270 tags: impl Iterator<Item = &'b str>,
271) -> Option<Vec<super::WebUiTemplatePayload<'a>>> {
272 let mut templates: Vec<super::WebUiTemplatePayload<'a>> = Vec::new();
273 for name in tags {
274 if let Some((tag_name, component)) = protocol.components.get_key_value(name) {
275 if !component.template_json.is_empty() {
276 templates.push(super::WebUiTemplatePayload {
277 tag_name: tag_name.as_str(),
278 template_json: component.template_json.as_str(),
279 template_functions: component.template_functions.as_str(),
280 });
281 }
282 }
283 }
284 if templates.is_empty() {
285 None
286 } else {
287 Some(templates)
288 }
289}
290
291fn webui_emit_bootstrap_fns(
295 payloads: &[super::WebUiTemplatePayload<'_>],
296 nonce: Option<&str>,
297 writer: &mut dyn ResponseWriter,
298) -> Result<()> {
299 let has_functions = payloads
300 .iter()
301 .any(|payload| !payload.template_functions.is_empty());
302 if !has_functions {
303 return Ok(());
304 }
305
306 if let Some(nonce) = nonce {
307 writer.write("<script nonce=\"")?;
308 writer.write(nonce)?;
309 writer.write("\">")?;
310 } else {
311 writer.write("<script>")?;
312 }
313 writer.write("(function(){var w=window.__webui||(window.__webui={});")?;
314 writer.write("var f=w.templateFns||(w.templateFns={});")?;
315 let mut json_scratch = Vec::new();
318 for payload in payloads {
319 if payload.template_functions.is_empty() {
320 continue;
321 }
322 writer.write("f[")?;
323 crate::write_script_safe_json(writer, &mut json_scratch, payload.tag_name)?;
324 writer.write("]=")?;
325 writer.write(payload.template_functions)?;
326 writer.write(";")?;
327 }
328 writer.write("})();</script>\n")
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 struct TestWriter {
336 output: String,
337 }
338
339 impl TestWriter {
340 fn new() -> Self {
341 Self {
342 output: String::new(),
343 }
344 }
345 }
346
347 impl ResponseWriter for TestWriter {
348 fn write(&mut self, content: &str) -> Result<()> {
349 self.output.push_str(content);
350 Ok(())
351 }
352 fn end(&mut self) -> Result<()> {
353 Ok(())
354 }
355 }
356
357 #[test]
358 fn test_default_creates_instance() {
359 let _plugin = WebUIHydrationPlugin::default();
360 }
361
362 #[test]
363 fn test_signal_binding_emits_no_output() {
364 let mut plugin = WebUIHydrationPlugin::new();
365 let mut writer = TestWriter::new();
366 plugin.on_binding_start("x", false, &mut writer).unwrap();
367 plugin.on_binding_end("x", false, &mut writer).unwrap();
368 assert_eq!(
369 writer.output, "",
370 "signal binding hooks must not emit output"
371 );
372 }
373
374 #[test]
375 fn test_raw_signal_binding_emits_range_markers() {
376 let mut plugin = WebUIHydrationPlugin::new();
377 let mut writer = TestWriter::new();
378 plugin.on_binding_start("html", true, &mut writer).unwrap();
379 writer.write("<b>trusted</b>").unwrap();
380 plugin.on_binding_end("html", true, &mut writer).unwrap();
381 assert_eq!(writer.output, "<!--w0--><b>trusted</b><!--/w0-->");
382 }
383
384 #[test]
385 fn test_for_loop_emits_repeat_markers() {
386 let mut plugin = WebUIHydrationPlugin::new();
387 let mut writer = TestWriter::new();
388 plugin.on_for_start("items", &mut writer).unwrap();
389 plugin.on_repeat_item_start(0, &mut writer).unwrap();
390 plugin.push_scope();
391 writer.write("<div>A</div>").unwrap();
392 plugin.pop_scope();
393 plugin.on_repeat_item_end(0, &mut writer).unwrap();
394 plugin.on_repeat_item_start(1, &mut writer).unwrap();
395 plugin.push_scope();
396 writer.write("<div>B</div>").unwrap();
397 plugin.pop_scope();
398 plugin.on_repeat_item_end(1, &mut writer).unwrap();
399 plugin.on_for_end("items", &mut writer).unwrap();
400 assert_eq!(
401 writer.output,
402 "<!--wr--><!--wi--><div>A</div><!--wi--><div>B</div><!--/wr-->"
403 );
404 }
405
406 #[test]
407 fn test_empty_for_loop_emits_boundary_markers() {
408 let mut plugin = WebUIHydrationPlugin::new();
409 let mut writer = TestWriter::new();
410 plugin.on_for_start("items", &mut writer).unwrap();
411 plugin.on_for_end("items", &mut writer).unwrap();
412 assert_eq!(writer.output, "<!--wr--><!--/wr-->");
413 }
414
415 #[test]
416 fn test_if_true_emits_cond_markers() {
417 let mut plugin = WebUIHydrationPlugin::new();
418 let mut writer = TestWriter::new();
419 plugin.on_if_start("show", &mut writer).unwrap();
420 plugin.push_scope();
421 writer.write("<p>visible</p>").unwrap();
422 plugin.pop_scope();
423 plugin.on_if_end("show", &mut writer).unwrap();
424 assert_eq!(writer.output, "<!--wc--><p>visible</p><!--/wc-->");
425 }
426
427 #[test]
428 fn test_if_false_emits_empty_cond_markers() {
429 let mut plugin = WebUIHydrationPlugin::new();
430 let mut writer = TestWriter::new();
431 plugin.on_if_start("show", &mut writer).unwrap();
432 plugin.on_if_end("show", &mut writer).unwrap();
434 assert_eq!(writer.output, "<!--wc--><!--/wc-->");
435 }
436
437 #[test]
438 fn test_nested_repeat_inside_conditional() {
439 let mut plugin = WebUIHydrationPlugin::new();
440 let mut writer = TestWriter::new();
441 plugin.on_if_start("show", &mut writer).unwrap();
442 plugin.push_scope();
443 plugin.on_for_start("items", &mut writer).unwrap();
444 plugin.on_repeat_item_start(0, &mut writer).unwrap();
445 plugin.push_scope();
446 writer.write("<li>X</li>").unwrap();
447 plugin.pop_scope();
448 plugin.on_repeat_item_end(0, &mut writer).unwrap();
449 plugin.on_for_end("items", &mut writer).unwrap();
450 plugin.pop_scope();
451 plugin.on_if_end("show", &mut writer).unwrap();
452 assert_eq!(
453 writer.output,
454 "<!--wc--><!--wr--><!--wi--><li>X</li><!--/wr--><!--/wc-->"
455 );
456 }
457
458 #[test]
459 fn test_element_data_emits_no_output() {
460 let mut plugin = WebUIHydrationPlugin::new();
461 let mut writer = TestWriter::new();
462 plugin.push_scope();
463 let data = 3u32.to_le_bytes();
464 plugin.on_element_data(&data, &mut writer).unwrap();
465 assert_eq!(writer.output, "", "element data must not emit output");
466 }
467
468 #[test]
469 fn test_scope_push_pop_is_noop() {
470 let mut plugin = WebUIHydrationPlugin::new();
471 plugin.push_scope();
472 plugin.push_scope();
473 plugin.pop_scope();
474 plugin.pop_scope();
475 }
476
477 #[test]
478 fn test_route_component_state_emits_no_output() {
479 let plugin = WebUIHydrationPlugin::new();
480 let mut writer = TestWriter::new();
481 let state = serde_json::json!({
482 "title": "Hello",
483 "cartOpen": true,
484 "items": [{"name": "A&B"}],
485 });
486 plugin
487 .write_route_component_state(&state, &mut writer)
488 .unwrap();
489 assert_eq!(
490 writer.output, "",
491 "route component state must not emit output"
492 );
493 }
494
495 #[test]
496 fn test_full_lifecycle_with_markers() {
497 let mut plugin = WebUIHydrationPlugin::new();
498 let mut writer = TestWriter::new();
499
500 plugin.push_scope();
502 plugin.on_binding_start("a", false, &mut writer).unwrap();
504 writer.write("hello").unwrap();
505 plugin.on_binding_end("a", false, &mut writer).unwrap();
506 plugin.on_for_start("list", &mut writer).unwrap();
508 plugin.on_repeat_item_start(0, &mut writer).unwrap();
509 plugin.push_scope();
510 writer.write("<x-item>1</x-item>").unwrap();
511 plugin.pop_scope();
512 plugin.on_repeat_item_end(0, &mut writer).unwrap();
513 plugin.on_for_end("list", &mut writer).unwrap();
514 plugin.on_if_start("flag", &mut writer).unwrap();
516 plugin.push_scope();
517 writer.write("<p>yes</p>").unwrap();
518 plugin.pop_scope();
519 plugin.on_if_end("flag", &mut writer).unwrap();
520 plugin.pop_scope();
521
522 assert_eq!(
523 writer.output,
524 "hello<!--wr--><!--wi--><x-item>1</x-item><!--/wr--><!--wc--><p>yes</p><!--/wc-->"
525 );
526 }
527
528 fn iife_template(tag: &str, body: &str) -> String {
529 format!("(function(){{var w=(window.__webui||(window.__webui={{}})).templates||(window.__webui.templates={{}});w['{tag}']={{{body}}}}})();\n")
530 }
531
532 #[test]
533 fn test_emit_templates_only_emits_provided_components() {
534 let mut writer = TestWriter::new();
535
536 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
537 protocol
538 .components
539 .entry("comp-a".to_string())
540 .or_default()
541 .template = iife_template("comp-a", "h:\"a\"");
542 protocol
543 .components
544 .entry("comp-b".to_string())
545 .or_default()
546 .template = iife_template("comp-b", "h:\"b\"");
547 protocol
548 .components
549 .entry("comp-c".to_string())
550 .or_default()
551 .template = iife_template("comp-c", "h:\"c\"");
552
553 let components = vec!["comp-a".to_string()];
554
555 let plugin = WebUIHydrationPlugin::new();
556 plugin
557 .emit_templates(&protocol, &components, None, &mut writer)
558 .unwrap();
559
560 assert!(
561 writer.output.contains("comp-a"),
562 "rendered component should be emitted: {}",
563 writer.output
564 );
565 assert!(
566 !writer.output.contains("comp-b"),
567 "non-rendered component should NOT be emitted: {}",
568 writer.output
569 );
570 assert!(
571 !writer.output.contains("comp-c"),
572 "non-rendered component should NOT be emitted: {}",
573 writer.output
574 );
575 }
576
577 #[test]
578 fn test_emit_templates_empty_set() {
579 let mut writer = TestWriter::new();
580 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
581 protocol
582 .components
583 .entry("comp-a".to_string())
584 .or_default()
585 .template = iife_template("comp-a", "h:\"a\"");
586 let components: Vec<String> = Vec::new();
587 let plugin = WebUIHydrationPlugin::new();
588 plugin
589 .emit_templates(&protocol, &components, None, &mut writer)
590 .unwrap();
591 assert_eq!(writer.output, "", "empty set should emit nothing");
592 }
593
594 #[test]
595 fn test_on_render_complete_consolidates_into_single_script() {
596 let mut writer = TestWriter::new();
597
598 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
599 protocol
600 .components
601 .entry("comp-a".to_string())
602 .or_default()
603 .template = iife_template("comp-a", "h:\"a\"");
604 protocol
605 .components
606 .entry("comp-b".to_string())
607 .or_default()
608 .template = iife_template("comp-b", "h:\"b\"");
609
610 let rendered = vec!["comp-a".to_string(), "comp-b".to_string()];
611
612 let plugin = WebUIHydrationPlugin::new();
613 plugin
614 .emit_templates(&protocol, &rendered, None, &mut writer)
615 .unwrap();
616
617 let script_count = writer.output.matches("<script").count();
618 assert_eq!(
619 script_count, 1,
620 "should emit exactly one <script> tag, got {script_count}: {}",
621 writer.output
622 );
623 assert!(writer.output.contains("comp-a"));
624 assert!(writer.output.contains("comp-b"));
625 }
626
627 #[test]
628 fn test_on_render_complete_skips_empty_template() {
629 let mut writer = TestWriter::new();
630 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
631 protocol
632 .components
633 .entry("comp-a".to_string())
634 .or_default()
635 .template = String::new();
636 let rendered = vec!["comp-a".to_string()];
637 let plugin = WebUIHydrationPlugin::new();
638 plugin
639 .emit_templates(&protocol, &rendered, None, &mut writer)
640 .unwrap();
641 assert_eq!(writer.output, "", "empty template should not be emitted");
642 }
643
644 #[test]
645 fn test_on_render_complete_unknown_component() {
646 let mut writer = TestWriter::new();
647 let protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
648 let rendered = vec!["nonexistent-comp".to_string()];
649 let plugin = WebUIHydrationPlugin::new();
650 plugin
651 .emit_templates(&protocol, &rendered, None, &mut writer)
652 .unwrap();
653 assert_eq!(
654 writer.output, "",
655 "unknown component should not cause error"
656 );
657 }
658
659 use std::collections::HashMap;
662 use webui_protocol::{ConditionExpr, FragmentList, WebUIFragment, WebUIProtocol};
663 use webui_test_utils::test_json;
664
665 use crate::{RenderOptions, WebUIHandler};
666
667 fn render_with_webui_plugin(protocol: &WebUIProtocol, state: &serde_json::Value) -> String {
668 let mut writer = TestWriter::new();
669 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
670 handler
671 .handle(
672 protocol,
673 state,
674 &RenderOptions::new("index.html", "/"),
675 &mut writer,
676 )
677 .unwrap();
678 writer.output
679 }
680
681 #[test]
682 fn test_handler_emits_hydration_markers_for_loop_and_if() {
683 let mut fragments = HashMap::new();
685 fragments.insert(
686 "index.html".to_string(),
687 FragmentList {
688 fragments: vec![
689 WebUIFragment::for_loop("item", "items", "for-body"),
690 WebUIFragment::if_cond(ConditionExpr::identifier("show"), "if-body"),
691 WebUIFragment::signal("html", true),
692 ],
693 contains_boundary: false,
694 },
695 );
696 fragments.insert(
697 "for-body".to_string(),
698 FragmentList {
699 fragments: vec![WebUIFragment::signal("item", false)],
700 contains_boundary: false,
701 },
702 );
703 fragments.insert(
704 "if-body".to_string(),
705 FragmentList {
706 fragments: vec![WebUIFragment::raw("<span>yes</span>")],
707 contains_boundary: false,
708 },
709 );
710 let protocol = WebUIProtocol::new(fragments);
711 let state = test_json!({"items": ["a", "b"], "show": true, "html": "<i>raw</i>"});
712 let output = render_with_webui_plugin(&protocol, &state);
713
714 assert!(
716 output.contains("<!--wr-->"),
717 "Expected repeat-start marker, got: {output}"
718 );
719 assert!(
720 output.contains("<!--/wr-->"),
721 "Expected repeat-end marker, got: {output}"
722 );
723 assert!(
724 output.contains("<!--wi-->"),
725 "Expected repeat-item marker, got: {output}"
726 );
727 assert_eq!(
729 output.matches("<!--wi-->").count(),
730 2,
731 "Expected 2 repeat-item markers, got: {output}"
732 );
733
734 assert!(
736 output.contains("<!--wc-->"),
737 "Expected cond-start marker, got: {output}"
738 );
739 assert!(
740 output.contains("<!--/wc-->"),
741 "Expected cond-end marker, got: {output}"
742 );
743
744 assert!(
746 output.contains("a"),
747 "Expected for-loop items in output, got: {output}"
748 );
749 assert!(
750 output.contains("b"),
751 "Expected for-loop items in output, got: {output}"
752 );
753 assert!(
754 output.contains("<span>yes</span>"),
755 "Expected if-condition body, got: {output}"
756 );
757 assert!(
758 output.contains("<!--w0--><i>raw</i><!--/w0-->"),
759 "Expected bounded raw HTML markers, got: {output}"
760 );
761 }
762
763 #[test]
764 fn test_handler_keeps_raw_text_signal_marker_free() {
765 let mut fragments = HashMap::new();
766 fragments.insert(
767 "index.html".to_string(),
768 FragmentList {
769 fragments: vec![
770 WebUIFragment::raw("<style>:root{"),
771 WebUIFragment::raw_text_signal("tokens", true),
772 WebUIFragment::raw("}</style>"),
773 ],
774 contains_boundary: false,
775 },
776 );
777 let protocol = WebUIProtocol::new(fragments);
778 let state = test_json!({"tokens": "--accent:red"});
779 let output = render_with_webui_plugin(&protocol, &state);
780
781 assert!(output.contains("<style>:root{--accent:red}</style>"));
782 assert!(!output.contains("<!--w0-->"));
783 assert!(!output.contains("<!--/w0-->"));
784 }
785}