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        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    /// Collect split WebUI template payloads for SSR bootstrap emission.
126    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
160/// Emit non-split WebUI component templates inside a single `<script>` tag.
161///
162/// Shared by the `HashSet`-based ordinary path and the `&[&str]`-based streaming
163/// path; the lookup-key lifetime `'b` is independent of the protocol so both
164/// callers pass borrowed tags without cloning.
165fn 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
203/// Collect split WebUI template payloads for the given component tags.
204///
205/// Returned payloads borrow the protocol (`'a`); the lookup-key lifetime `'b`
206/// is independent so both the `HashSet` and slice callers share this helper.
207fn 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
230/// Emit the WebUI `templateFns` executable side-channel for payloads that carry
231/// component-local condition closures. Shared by the ordinary and streaming
232/// bootstrap-extension hooks.
233fn 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    // Request-local scratch reused across every tag_name serialized in this
255    // loop; dropped when the emission returns.
256    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        // condition is false — no content rendered
362        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        // Simulate a component with a signal, repeat, and conditional.
430        plugin.push_scope();
431        // Signal binding — no markers
432        plugin.on_binding_start("a", &mut writer).unwrap();
433        writer.write("hello").unwrap();
434        plugin.on_binding_end("a", &mut writer).unwrap();
435        // For-loop — markers
436        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        // Conditional — markers
444        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    // ── Integration test (full render cycle with WebUIHandler) ──────────
594
595    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        // Build a protocol with a for-loop (2 items) and an if-condition.
618        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        // For-loop markers
645        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        // Each item should produce a <!--wi--> marker
658        assert_eq!(
659            output.matches("<!--wi-->").count(),
660            2,
661            "Expected 2 repeat-item markers, got: {output}"
662        );
663
664        // If-condition markers
665        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        // Content is rendered
675        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}