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};
14
15const REPEAT_START: &str = "<!--wr-->";
16const REPEAT_END: &str = "<!--/wr-->";
17const REPEAT_ITEM: &str = "<!--wi-->";
18const COND_START: &str = "<!--wc-->";
19const COND_END: &str = "<!--/wc-->";
20
21/// WebUI handler plugin that emits hydration markers.
22///
23/// Emits lightweight HTML comment markers around structural boundaries
24/// (for-loops and if-conditions) so the client can hydrate in-place
25/// without reparenting DOM nodes.
26pub struct WebUIHydrationPlugin;
27
28impl WebUIHydrationPlugin {
29    /// Create a new WebUI handler plugin.
30    #[must_use]
31    pub fn new() -> Self {
32        Self
33    }
34}
35
36impl Default for WebUIHydrationPlugin {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl HandlerPlugin for WebUIHydrationPlugin {
43    fn push_scope(&mut self) {}
44
45    fn pop_scope(&mut self) {}
46
47    fn on_binding_start(&mut self, _name: &str, _writer: &mut dyn ResponseWriter) -> Result<()> {
48        Ok(())
49    }
50
51    fn on_binding_end(&mut self, _name: &str, _writer: &mut dyn ResponseWriter) -> Result<()> {
52        Ok(())
53    }
54
55    fn on_for_start(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
56        writer.write(REPEAT_START)
57    }
58
59    fn on_for_end(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
60        writer.write(REPEAT_END)
61    }
62
63    fn on_if_start(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
64        writer.write(COND_START)
65    }
66
67    fn on_if_end(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
68        writer.write(COND_END)
69    }
70
71    fn on_repeat_item_start(
72        &mut self,
73        _index: usize,
74        writer: &mut dyn ResponseWriter,
75    ) -> Result<()> {
76        writer.write(REPEAT_ITEM)
77    }
78
79    // No end marker needed — the next <!--wi--> or <!--/wr--> serves as the boundary.
80    fn on_repeat_item_end(
81        &mut self,
82        _index: usize,
83        _writer: &mut dyn ResponseWriter,
84    ) -> Result<()> {
85        Ok(())
86    }
87
88    fn on_element_data(&mut self, _data: &[u8], _writer: &mut dyn ResponseWriter) -> Result<()> {
89        Ok(())
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    struct TestWriter {
98        output: String,
99    }
100
101    impl TestWriter {
102        fn new() -> Self {
103            Self {
104                output: String::new(),
105            }
106        }
107    }
108
109    impl ResponseWriter for TestWriter {
110        fn write(&mut self, content: &str) -> Result<()> {
111            self.output.push_str(content);
112            Ok(())
113        }
114        fn end(&mut self) -> Result<()> {
115            Ok(())
116        }
117    }
118
119    #[test]
120    fn test_default_creates_instance() {
121        let _plugin = WebUIHydrationPlugin::default();
122    }
123
124    #[test]
125    fn test_signal_binding_emits_no_output() {
126        let mut plugin = WebUIHydrationPlugin::new();
127        let mut writer = TestWriter::new();
128        plugin.on_binding_start("x", &mut writer).unwrap();
129        plugin.on_binding_end("x", &mut writer).unwrap();
130        assert_eq!(
131            writer.output, "",
132            "signal binding hooks must not emit output"
133        );
134    }
135
136    #[test]
137    fn test_for_loop_emits_repeat_markers() {
138        let mut plugin = WebUIHydrationPlugin::new();
139        let mut writer = TestWriter::new();
140        plugin.on_for_start("items", &mut writer).unwrap();
141        plugin.on_repeat_item_start(0, &mut writer).unwrap();
142        plugin.push_scope();
143        writer.write("<div>A</div>").unwrap();
144        plugin.pop_scope();
145        plugin.on_repeat_item_end(0, &mut writer).unwrap();
146        plugin.on_repeat_item_start(1, &mut writer).unwrap();
147        plugin.push_scope();
148        writer.write("<div>B</div>").unwrap();
149        plugin.pop_scope();
150        plugin.on_repeat_item_end(1, &mut writer).unwrap();
151        plugin.on_for_end("items", &mut writer).unwrap();
152        assert_eq!(
153            writer.output,
154            "<!--wr--><!--wi--><div>A</div><!--wi--><div>B</div><!--/wr-->"
155        );
156    }
157
158    #[test]
159    fn test_empty_for_loop_emits_boundary_markers() {
160        let mut plugin = WebUIHydrationPlugin::new();
161        let mut writer = TestWriter::new();
162        plugin.on_for_start("items", &mut writer).unwrap();
163        plugin.on_for_end("items", &mut writer).unwrap();
164        assert_eq!(writer.output, "<!--wr--><!--/wr-->");
165    }
166
167    #[test]
168    fn test_if_true_emits_cond_markers() {
169        let mut plugin = WebUIHydrationPlugin::new();
170        let mut writer = TestWriter::new();
171        plugin.on_if_start("show", &mut writer).unwrap();
172        plugin.push_scope();
173        writer.write("<p>visible</p>").unwrap();
174        plugin.pop_scope();
175        plugin.on_if_end("show", &mut writer).unwrap();
176        assert_eq!(writer.output, "<!--wc--><p>visible</p><!--/wc-->");
177    }
178
179    #[test]
180    fn test_if_false_emits_empty_cond_markers() {
181        let mut plugin = WebUIHydrationPlugin::new();
182        let mut writer = TestWriter::new();
183        plugin.on_if_start("show", &mut writer).unwrap();
184        // condition is false — no content rendered
185        plugin.on_if_end("show", &mut writer).unwrap();
186        assert_eq!(writer.output, "<!--wc--><!--/wc-->");
187    }
188
189    #[test]
190    fn test_nested_repeat_inside_conditional() {
191        let mut plugin = WebUIHydrationPlugin::new();
192        let mut writer = TestWriter::new();
193        plugin.on_if_start("show", &mut writer).unwrap();
194        plugin.push_scope();
195        plugin.on_for_start("items", &mut writer).unwrap();
196        plugin.on_repeat_item_start(0, &mut writer).unwrap();
197        plugin.push_scope();
198        writer.write("<li>X</li>").unwrap();
199        plugin.pop_scope();
200        plugin.on_repeat_item_end(0, &mut writer).unwrap();
201        plugin.on_for_end("items", &mut writer).unwrap();
202        plugin.pop_scope();
203        plugin.on_if_end("show", &mut writer).unwrap();
204        assert_eq!(
205            writer.output,
206            "<!--wc--><!--wr--><!--wi--><li>X</li><!--/wr--><!--/wc-->"
207        );
208    }
209
210    #[test]
211    fn test_element_data_emits_no_output() {
212        let mut plugin = WebUIHydrationPlugin::new();
213        let mut writer = TestWriter::new();
214        plugin.push_scope();
215        let data = 3u32.to_le_bytes();
216        plugin.on_element_data(&data, &mut writer).unwrap();
217        assert_eq!(writer.output, "", "element data must not emit output");
218    }
219
220    #[test]
221    fn test_scope_push_pop_is_noop() {
222        let mut plugin = WebUIHydrationPlugin::new();
223        plugin.push_scope();
224        plugin.push_scope();
225        plugin.pop_scope();
226        plugin.pop_scope();
227    }
228
229    #[test]
230    fn test_route_component_state_emits_no_output() {
231        let plugin = WebUIHydrationPlugin::new();
232        let mut writer = TestWriter::new();
233        let state = serde_json::json!({
234            "title": "Hello",
235            "cartOpen": true,
236            "items": [{"name": "A&B"}],
237        });
238        plugin
239            .write_route_component_state(&state, &mut writer)
240            .unwrap();
241        assert_eq!(
242            writer.output, "",
243            "route component state must not emit output"
244        );
245    }
246
247    #[test]
248    fn test_full_lifecycle_with_markers() {
249        let mut plugin = WebUIHydrationPlugin::new();
250        let mut writer = TestWriter::new();
251
252        // Simulate a component with a signal, repeat, and conditional.
253        plugin.push_scope();
254        // Signal binding — no markers
255        plugin.on_binding_start("a", &mut writer).unwrap();
256        writer.write("hello").unwrap();
257        plugin.on_binding_end("a", &mut writer).unwrap();
258        // For-loop — markers
259        plugin.on_for_start("list", &mut writer).unwrap();
260        plugin.on_repeat_item_start(0, &mut writer).unwrap();
261        plugin.push_scope();
262        writer.write("<x-item>1</x-item>").unwrap();
263        plugin.pop_scope();
264        plugin.on_repeat_item_end(0, &mut writer).unwrap();
265        plugin.on_for_end("list", &mut writer).unwrap();
266        // Conditional — markers
267        plugin.on_if_start("flag", &mut writer).unwrap();
268        plugin.push_scope();
269        writer.write("<p>yes</p>").unwrap();
270        plugin.pop_scope();
271        plugin.on_if_end("flag", &mut writer).unwrap();
272        plugin.pop_scope();
273
274        assert_eq!(
275            writer.output,
276            "hello<!--wr--><!--wi--><x-item>1</x-item><!--/wr--><!--wc--><p>yes</p><!--/wc-->"
277        );
278    }
279
280    #[test]
281    fn test_on_render_complete_only_emits_rendered_components() {
282        let mut writer = TestWriter::new();
283
284        let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
285        protocol
286            .components
287            .entry("comp-a".to_string())
288            .or_default()
289            .template = "<script>/*comp-a*/</script>".to_string();
290        protocol
291            .components
292            .entry("comp-b".to_string())
293            .or_default()
294            .template = "<script>/*comp-b*/</script>".to_string();
295        protocol
296            .components
297            .entry("comp-c".to_string())
298            .or_default()
299            .template = "<script>/*comp-c*/</script>".to_string();
300
301        let mut rendered = std::collections::HashSet::new();
302        rendered.insert("comp-a".to_string());
303
304        crate::plugin::emit_rendered_component_templates(&protocol, &rendered, None, &mut writer)
305            .unwrap();
306
307        assert!(
308            writer.output.contains("comp-a"),
309            "rendered component should be emitted: {}",
310            writer.output
311        );
312        assert!(
313            !writer.output.contains("comp-b"),
314            "non-rendered component should NOT be emitted: {}",
315            writer.output
316        );
317        assert!(
318            !writer.output.contains("comp-c"),
319            "non-rendered component should NOT be emitted: {}",
320            writer.output
321        );
322    }
323
324    #[test]
325    fn test_on_render_complete_empty_rendered_set() {
326        let mut writer = TestWriter::new();
327        let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
328        protocol
329            .components
330            .entry("comp-a".to_string())
331            .or_default()
332            .template = "<script>/*a*/</script>".to_string();
333        let rendered = std::collections::HashSet::new();
334        crate::plugin::emit_rendered_component_templates(&protocol, &rendered, None, &mut writer)
335            .unwrap();
336        assert_eq!(writer.output, "", "empty rendered set should emit nothing");
337    }
338
339    #[test]
340    fn test_on_render_complete_skips_empty_template() {
341        let mut writer = TestWriter::new();
342        let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
343        protocol
344            .components
345            .entry("comp-a".to_string())
346            .or_default()
347            .template = String::new();
348        let mut rendered = std::collections::HashSet::new();
349        rendered.insert("comp-a".to_string());
350        crate::plugin::emit_rendered_component_templates(&protocol, &rendered, None, &mut writer)
351            .unwrap();
352        assert_eq!(writer.output, "", "empty template should not be emitted");
353    }
354
355    #[test]
356    fn test_on_render_complete_unknown_component() {
357        let mut writer = TestWriter::new();
358        let protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
359        let mut rendered = std::collections::HashSet::new();
360        rendered.insert("nonexistent-comp".to_string());
361        crate::plugin::emit_rendered_component_templates(&protocol, &rendered, None, &mut writer)
362            .unwrap();
363        assert_eq!(
364            writer.output, "",
365            "unknown component should not cause error"
366        );
367    }
368
369    // ── Integration test (full render cycle with WebUIHandler) ──────────
370
371    use std::collections::HashMap;
372    use webui_protocol::{ConditionExpr, FragmentList, WebUIFragment, WebUIProtocol};
373    use webui_test_utils::test_json;
374
375    use crate::{RenderOptions, WebUIHandler};
376
377    fn render_with_webui_plugin(protocol: &WebUIProtocol, state: &serde_json::Value) -> String {
378        let mut writer = TestWriter::new();
379        let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
380        handler
381            .handle(
382                protocol,
383                state,
384                &RenderOptions::new("index.html", "/"),
385                &mut writer,
386            )
387            .unwrap();
388        writer.output
389    }
390
391    #[test]
392    fn test_handler_emits_hydration_markers_for_loop_and_if() {
393        // Build a protocol with a for-loop (2 items) and an if-condition.
394        let mut fragments = HashMap::new();
395        fragments.insert(
396            "index.html".to_string(),
397            FragmentList {
398                fragments: vec![
399                    WebUIFragment::for_loop("item", "items", "for-body"),
400                    WebUIFragment::if_cond(ConditionExpr::identifier("show"), "if-body"),
401                ],
402            },
403        );
404        fragments.insert(
405            "for-body".to_string(),
406            FragmentList {
407                fragments: vec![WebUIFragment::signal("item", false)],
408            },
409        );
410        fragments.insert(
411            "if-body".to_string(),
412            FragmentList {
413                fragments: vec![WebUIFragment::raw("<span>yes</span>")],
414            },
415        );
416        let protocol = WebUIProtocol::new(fragments);
417        let state = test_json!({"items": ["a", "b"], "show": true});
418        let output = render_with_webui_plugin(&protocol, &state);
419
420        // For-loop markers
421        assert!(
422            output.contains("<!--wr-->"),
423            "Expected repeat-start marker, got: {output}"
424        );
425        assert!(
426            output.contains("<!--/wr-->"),
427            "Expected repeat-end marker, got: {output}"
428        );
429        assert!(
430            output.contains("<!--wi-->"),
431            "Expected repeat-item marker, got: {output}"
432        );
433        // Each item should produce a <!--wi--> marker
434        assert_eq!(
435            output.matches("<!--wi-->").count(),
436            2,
437            "Expected 2 repeat-item markers, got: {output}"
438        );
439
440        // If-condition markers
441        assert!(
442            output.contains("<!--wc-->"),
443            "Expected cond-start marker, got: {output}"
444        );
445        assert!(
446            output.contains("<!--/wc-->"),
447            "Expected cond-end marker, got: {output}"
448        );
449
450        // Content is rendered
451        assert!(
452            output.contains("a"),
453            "Expected for-loop items in output, got: {output}"
454        );
455        assert!(
456            output.contains("b"),
457            "Expected for-loop items in output, got: {output}"
458        );
459        assert!(
460            output.contains("<span>yes</span>"),
461            "Expected if-condition body, got: {output}"
462        );
463    }
464}