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