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::{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
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 legacy non-split WebUI component templates inside a single
95    /// `<script>` tag for fallback paths.
96    ///
97    /// NOTE: When `collect_template_payloads` returns `Some(...)`, `lib.rs`
98    /// emits template metadata in an `application/json` block and this method
99    /// is **not** called. It remains for non-WebUI plugin fallback paths.
100    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    /// Collect split WebUI template payloads for SSR bootstrap emission.
140    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        // condition is false — no content rendered
293        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        // Simulate a component with a signal, repeat, and conditional.
361        plugin.push_scope();
362        // Signal binding — no markers
363        plugin.on_binding_start("a", &mut writer).unwrap();
364        writer.write("hello").unwrap();
365        plugin.on_binding_end("a", &mut writer).unwrap();
366        // For-loop — markers
367        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        // Conditional — markers
375        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    // ── Integration test (full render cycle with WebUIHandler) ──────────
525
526    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        // Build a protocol with a for-loop (2 items) and an if-condition.
549        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        // For-loop markers
576        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        // Each item should produce a <!--wi--> marker
589        assert_eq!(
590            output.matches("<!--wi-->").count(),
591            2,
592            "Expected 2 repeat-item markers, got: {output}"
593        );
594
595        // If-condition markers
596        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        // Content is rendered
606        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}