Skip to main content

webui_handler/plugin/
webui.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! WebUI handler plugin that emits lightweight hydration markers.
5//!
6//! Emits comment markers around for-loop blocks (`<!--wr-->` / `<!--/wr-->`),
7//! before each repeat item (`<!--wi-->`), and around if-condition blocks
8//! (`<!--wc-->` / `<!--/wc-->`). These markers enable zero-DOM-mutation
9//! in-place hydration on the client: the framework reuses the SSR comment
10//! nodes as runtime anchors instead of creating temporary wrappers.
11
12use super::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
23/// WebUI handler plugin that emits hydration markers.
24///
25/// Emits lightweight HTML comment markers around structural boundaries
26/// (for-loops and if-conditions) so the client can hydrate in-place
27/// without reparenting DOM nodes.
28pub struct WebUIHydrationPlugin;
29
30impl WebUIHydrationPlugin {
31    /// Create a new WebUI handler plugin.
32    #[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    // No end marker needed — the next <!--wi--> or <!--/wr--> serves as the boundary.
82    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    /// Emit all WebUI component templates inside a single `<script>` tag
95    /// for SSR.  Templates are raw JS IIFE strings (no `<script>` wrapper)
96    /// stored in the protocol — the `<script>` tag is only added here for
97    /// the SSR HTML output.  Partial SPA responses send the raw JS directly
98    /// and the router evaluates it client-side.
99    ///
100    /// NOTE: When `collect_template_js` returns `Some(...)`, `lib.rs` merges
101    /// the templates into the consolidated `window.__webui` script block and
102    /// this method is **not** called.  It remains as a fallback for
103    /// non-consolidated code paths.
104    fn emit_templates(
105        &self,
106        protocol: &WebUIProtocol,
107        components: &HashSet<String>,
108        nonce: Option<&str>,
109        writer: &mut dyn ResponseWriter,
110    ) -> Result<()> {
111        let mut templates: Vec<&str> = Vec::with_capacity(components.len());
112
113        for name in components {
114            if let Some(template) = protocol
115                .components
116                .get(name)
117                .map(|component| component.template.as_str())
118                .filter(|t| !t.is_empty())
119            {
120                templates.push(template);
121            }
122        }
123
124        if templates.is_empty() {
125            return Ok(());
126        }
127
128        if let Some(nonce) = nonce {
129            writer.write("<script nonce=\"")?;
130            writer.write(nonce)?;
131            writer.write("\">\n")?;
132        } else {
133            writer.write("<script>\n")?;
134        }
135        for tmpl in &templates {
136            writer.write(tmpl)?;
137        }
138        writer.write("</script>\n")?;
139
140        Ok(())
141    }
142
143    /// Collect raw JS template IIFE strings for embedding in the consolidated
144    /// `window.__webui` script block.  Returns `Some(vec)` with non-empty
145    /// template sources, or `None` if there are no templates to emit.
146    fn collect_template_js(
147        &self,
148        protocol: &WebUIProtocol,
149        components: &HashSet<String>,
150    ) -> Option<Vec<String>> {
151        let mut templates: Vec<String> = Vec::with_capacity(components.len());
152        for name in components {
153            if let Some(template) = protocol
154                .components
155                .get(name)
156                .map(|component| component.template.as_str())
157                .filter(|t| !t.is_empty())
158            {
159                templates.push(template.to_string());
160            }
161        }
162        if templates.is_empty() {
163            None
164        } else {
165            Some(templates)
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    struct TestWriter {
175        output: String,
176    }
177
178    impl TestWriter {
179        fn new() -> Self {
180            Self {
181                output: String::new(),
182            }
183        }
184    }
185
186    impl ResponseWriter for TestWriter {
187        fn write(&mut self, content: &str) -> Result<()> {
188            self.output.push_str(content);
189            Ok(())
190        }
191        fn end(&mut self) -> Result<()> {
192            Ok(())
193        }
194    }
195
196    #[test]
197    fn test_default_creates_instance() {
198        let _plugin = WebUIHydrationPlugin;
199    }
200
201    #[test]
202    fn test_signal_binding_emits_no_output() {
203        let mut plugin = WebUIHydrationPlugin::new();
204        let mut writer = TestWriter::new();
205        plugin.on_binding_start("x", &mut writer).unwrap();
206        plugin.on_binding_end("x", &mut writer).unwrap();
207        assert_eq!(
208            writer.output, "",
209            "signal binding hooks must not emit output"
210        );
211    }
212
213    #[test]
214    fn test_for_loop_emits_repeat_markers() {
215        let mut plugin = WebUIHydrationPlugin::new();
216        let mut writer = TestWriter::new();
217        plugin.on_for_start("items", &mut writer).unwrap();
218        plugin.on_repeat_item_start(0, &mut writer).unwrap();
219        plugin.push_scope();
220        writer.write("<div>A</div>").unwrap();
221        plugin.pop_scope();
222        plugin.on_repeat_item_end(0, &mut writer).unwrap();
223        plugin.on_repeat_item_start(1, &mut writer).unwrap();
224        plugin.push_scope();
225        writer.write("<div>B</div>").unwrap();
226        plugin.pop_scope();
227        plugin.on_repeat_item_end(1, &mut writer).unwrap();
228        plugin.on_for_end("items", &mut writer).unwrap();
229        assert_eq!(
230            writer.output,
231            "<!--wr--><!--wi--><div>A</div><!--wi--><div>B</div><!--/wr-->"
232        );
233    }
234
235    #[test]
236    fn test_empty_for_loop_emits_boundary_markers() {
237        let mut plugin = WebUIHydrationPlugin::new();
238        let mut writer = TestWriter::new();
239        plugin.on_for_start("items", &mut writer).unwrap();
240        plugin.on_for_end("items", &mut writer).unwrap();
241        assert_eq!(writer.output, "<!--wr--><!--/wr-->");
242    }
243
244    #[test]
245    fn test_if_true_emits_cond_markers() {
246        let mut plugin = WebUIHydrationPlugin::new();
247        let mut writer = TestWriter::new();
248        plugin.on_if_start("show", &mut writer).unwrap();
249        plugin.push_scope();
250        writer.write("<p>visible</p>").unwrap();
251        plugin.pop_scope();
252        plugin.on_if_end("show", &mut writer).unwrap();
253        assert_eq!(writer.output, "<!--wc--><p>visible</p><!--/wc-->");
254    }
255
256    #[test]
257    fn test_if_false_emits_empty_cond_markers() {
258        let mut plugin = WebUIHydrationPlugin::new();
259        let mut writer = TestWriter::new();
260        plugin.on_if_start("show", &mut writer).unwrap();
261        // condition is false — no content rendered
262        plugin.on_if_end("show", &mut writer).unwrap();
263        assert_eq!(writer.output, "<!--wc--><!--/wc-->");
264    }
265
266    #[test]
267    fn test_nested_repeat_inside_conditional() {
268        let mut plugin = WebUIHydrationPlugin::new();
269        let mut writer = TestWriter::new();
270        plugin.on_if_start("show", &mut writer).unwrap();
271        plugin.push_scope();
272        plugin.on_for_start("items", &mut writer).unwrap();
273        plugin.on_repeat_item_start(0, &mut writer).unwrap();
274        plugin.push_scope();
275        writer.write("<li>X</li>").unwrap();
276        plugin.pop_scope();
277        plugin.on_repeat_item_end(0, &mut writer).unwrap();
278        plugin.on_for_end("items", &mut writer).unwrap();
279        plugin.pop_scope();
280        plugin.on_if_end("show", &mut writer).unwrap();
281        assert_eq!(
282            writer.output,
283            "<!--wc--><!--wr--><!--wi--><li>X</li><!--/wr--><!--/wc-->"
284        );
285    }
286
287    #[test]
288    fn test_element_data_emits_no_output() {
289        let mut plugin = WebUIHydrationPlugin::new();
290        let mut writer = TestWriter::new();
291        plugin.push_scope();
292        let data = 3u32.to_le_bytes();
293        plugin.on_element_data(&data, &mut writer).unwrap();
294        assert_eq!(writer.output, "", "element data must not emit output");
295    }
296
297    #[test]
298    fn test_scope_push_pop_is_noop() {
299        let mut plugin = WebUIHydrationPlugin::new();
300        plugin.push_scope();
301        plugin.push_scope();
302        plugin.pop_scope();
303        plugin.pop_scope();
304    }
305
306    #[test]
307    fn test_route_component_state_emits_no_output() {
308        let plugin = WebUIHydrationPlugin::new();
309        let mut writer = TestWriter::new();
310        let state = serde_json::json!({
311            "title": "Hello",
312            "cartOpen": true,
313            "items": [{"name": "A&B"}],
314        });
315        plugin
316            .write_route_component_state(&state, &mut writer)
317            .unwrap();
318        assert_eq!(
319            writer.output, "",
320            "route component state must not emit output"
321        );
322    }
323
324    #[test]
325    fn test_full_lifecycle_with_markers() {
326        let mut plugin = WebUIHydrationPlugin::new();
327        let mut writer = TestWriter::new();
328
329        // Simulate a component with a signal, repeat, and conditional.
330        plugin.push_scope();
331        // Signal binding — no markers
332        plugin.on_binding_start("a", &mut writer).unwrap();
333        writer.write("hello").unwrap();
334        plugin.on_binding_end("a", &mut writer).unwrap();
335        // For-loop — markers
336        plugin.on_for_start("list", &mut writer).unwrap();
337        plugin.on_repeat_item_start(0, &mut writer).unwrap();
338        plugin.push_scope();
339        writer.write("<x-item>1</x-item>").unwrap();
340        plugin.pop_scope();
341        plugin.on_repeat_item_end(0, &mut writer).unwrap();
342        plugin.on_for_end("list", &mut writer).unwrap();
343        // Conditional — markers
344        plugin.on_if_start("flag", &mut writer).unwrap();
345        plugin.push_scope();
346        writer.write("<p>yes</p>").unwrap();
347        plugin.pop_scope();
348        plugin.on_if_end("flag", &mut writer).unwrap();
349        plugin.pop_scope();
350
351        assert_eq!(
352            writer.output,
353            "hello<!--wr--><!--wi--><x-item>1</x-item><!--/wr--><!--wc--><p>yes</p><!--/wc-->"
354        );
355    }
356
357    fn iife_template(tag: &str, body: &str) -> String {
358        format!("(function(){{var w=(window.__webui||(window.__webui={{}})).templates||(window.__webui.templates={{}});w['{tag}']={{{body}}}}})();\n")
359    }
360
361    #[test]
362    fn test_emit_templates_only_emits_provided_components() {
363        let mut writer = TestWriter::new();
364
365        let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
366        protocol
367            .components
368            .entry("comp-a".to_string())
369            .or_default()
370            .template = iife_template("comp-a", "h:\"a\"");
371        protocol
372            .components
373            .entry("comp-b".to_string())
374            .or_default()
375            .template = iife_template("comp-b", "h:\"b\"");
376        protocol
377            .components
378            .entry("comp-c".to_string())
379            .or_default()
380            .template = iife_template("comp-c", "h:\"c\"");
381
382        let mut components = std::collections::HashSet::new();
383        components.insert("comp-a".to_string());
384
385        let plugin = WebUIHydrationPlugin::new();
386        plugin
387            .emit_templates(&protocol, &components, None, &mut writer)
388            .unwrap();
389
390        assert!(
391            writer.output.contains("comp-a"),
392            "rendered component should be emitted: {}",
393            writer.output
394        );
395        assert!(
396            !writer.output.contains("comp-b"),
397            "non-rendered component should NOT be emitted: {}",
398            writer.output
399        );
400        assert!(
401            !writer.output.contains("comp-c"),
402            "non-rendered component should NOT be emitted: {}",
403            writer.output
404        );
405    }
406
407    #[test]
408    fn test_emit_templates_empty_set() {
409        let mut writer = TestWriter::new();
410        let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
411        protocol
412            .components
413            .entry("comp-a".to_string())
414            .or_default()
415            .template = iife_template("comp-a", "h:\"a\"");
416        let components = std::collections::HashSet::new();
417        let plugin = WebUIHydrationPlugin::new();
418        plugin
419            .emit_templates(&protocol, &components, None, &mut writer)
420            .unwrap();
421        assert_eq!(writer.output, "", "empty set should emit nothing");
422    }
423
424    #[test]
425    fn test_on_render_complete_consolidates_into_single_script() {
426        let mut writer = TestWriter::new();
427
428        let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
429        protocol
430            .components
431            .entry("comp-a".to_string())
432            .or_default()
433            .template = iife_template("comp-a", "h:\"a\"");
434        protocol
435            .components
436            .entry("comp-b".to_string())
437            .or_default()
438            .template = iife_template("comp-b", "h:\"b\"");
439
440        let mut rendered = std::collections::HashSet::new();
441        rendered.insert("comp-a".to_string());
442        rendered.insert("comp-b".to_string());
443
444        let plugin = WebUIHydrationPlugin::new();
445        plugin
446            .emit_templates(&protocol, &rendered, None, &mut writer)
447            .unwrap();
448
449        let script_count = writer.output.matches("<script").count();
450        assert_eq!(
451            script_count, 1,
452            "should emit exactly one <script> tag, got {script_count}: {}",
453            writer.output
454        );
455        assert!(writer.output.contains("comp-a"));
456        assert!(writer.output.contains("comp-b"));
457    }
458
459    #[test]
460    fn test_on_render_complete_skips_empty_template() {
461        let mut writer = TestWriter::new();
462        let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
463        protocol
464            .components
465            .entry("comp-a".to_string())
466            .or_default()
467            .template = String::new();
468        let mut rendered = std::collections::HashSet::new();
469        rendered.insert("comp-a".to_string());
470        let plugin = WebUIHydrationPlugin::new();
471        plugin
472            .emit_templates(&protocol, &rendered, None, &mut writer)
473            .unwrap();
474        assert_eq!(writer.output, "", "empty template should not be emitted");
475    }
476
477    #[test]
478    fn test_on_render_complete_unknown_component() {
479        let mut writer = TestWriter::new();
480        let protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
481        let mut rendered = std::collections::HashSet::new();
482        rendered.insert("nonexistent-comp".to_string());
483        let plugin = WebUIHydrationPlugin::new();
484        plugin
485            .emit_templates(&protocol, &rendered, None, &mut writer)
486            .unwrap();
487        assert_eq!(
488            writer.output, "",
489            "unknown component should not cause error"
490        );
491    }
492
493    // ── Integration test (full render cycle with WebUIHandler) ──────────
494
495    use std::collections::HashMap;
496    use webui_protocol::{ConditionExpr, FragmentList, WebUIFragment, WebUIProtocol};
497    use webui_test_utils::test_json;
498
499    use crate::{RenderOptions, WebUIHandler};
500
501    fn render_with_webui_plugin(protocol: &WebUIProtocol, state: &serde_json::Value) -> String {
502        let mut writer = TestWriter::new();
503        let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
504        handler
505            .handle(
506                protocol,
507                state,
508                &RenderOptions::new("index.html", "/"),
509                &mut writer,
510            )
511            .unwrap();
512        writer.output
513    }
514
515    #[test]
516    fn test_handler_emits_hydration_markers_for_loop_and_if() {
517        // Build a protocol with a for-loop (2 items) and an if-condition.
518        let mut fragments = HashMap::new();
519        fragments.insert(
520            "index.html".to_string(),
521            FragmentList {
522                fragments: vec![
523                    WebUIFragment::for_loop("item", "items", "for-body"),
524                    WebUIFragment::if_cond(ConditionExpr::identifier("show"), "if-body"),
525                ],
526            },
527        );
528        fragments.insert(
529            "for-body".to_string(),
530            FragmentList {
531                fragments: vec![WebUIFragment::signal("item", false)],
532            },
533        );
534        fragments.insert(
535            "if-body".to_string(),
536            FragmentList {
537                fragments: vec![WebUIFragment::raw("<span>yes</span>")],
538            },
539        );
540        let protocol = WebUIProtocol::new(fragments);
541        let state = test_json!({"items": ["a", "b"], "show": true});
542        let output = render_with_webui_plugin(&protocol, &state);
543
544        // For-loop markers
545        assert!(
546            output.contains("<!--wr-->"),
547            "Expected repeat-start marker, got: {output}"
548        );
549        assert!(
550            output.contains("<!--/wr-->"),
551            "Expected repeat-end marker, got: {output}"
552        );
553        assert!(
554            output.contains("<!--wi-->"),
555            "Expected repeat-item marker, got: {output}"
556        );
557        // Each item should produce a <!--wi--> marker
558        assert_eq!(
559            output.matches("<!--wi-->").count(),
560            2,
561            "Expected 2 repeat-item markers, got: {output}"
562        );
563
564        // If-condition markers
565        assert!(
566            output.contains("<!--wc-->"),
567            "Expected cond-start marker, got: {output}"
568        );
569        assert!(
570            output.contains("<!--/wc-->"),
571            "Expected cond-end marker, got: {output}"
572        );
573
574        // Content is rendered
575        assert!(
576            output.contains("a"),
577            "Expected for-loop items in output, got: {output}"
578        );
579        assert!(
580            output.contains("b"),
581            "Expected for-loop items in output, got: {output}"
582        );
583        assert!(
584            output.contains("<span>yes</span>"),
585            "Expected if-condition body, got: {output}"
586        );
587    }
588}