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-->`), around if-condition blocks
8//! (`<!--wc-->` / `<!--/wc-->`), and around raw HTML bindings
9//! (`<!--wN-->` / `<!--/wN-->`). These markers enable zero-DOM-mutation
10//! in-place hydration on the client: the framework reuses the SSR comment
11//! nodes as runtime anchors instead of creating temporary wrappers.
12
13use super::{BootstrapExtensionContext, HandlerPlugin};
14use crate::{ResponseWriter, Result};
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-->";
22const RAW_START_PREFIX: &str = "<!--w";
23const RAW_END_PREFIX: &str = "<!--/w";
24const MARKER_SUFFIX: &str = "-->";
25
26/// WebUI handler plugin that emits hydration markers.
27///
28/// Emits lightweight HTML comment markers around structural boundaries
29/// (for-loops and if-conditions) so the client can hydrate in-place
30/// without reparenting DOM nodes.
31pub struct WebUIHydrationPlugin {
32    raw_index: usize,
33    raw_marker: String,
34}
35
36impl WebUIHydrationPlugin {
37    /// Create a new WebUI handler plugin.
38    #[must_use]
39    pub fn new() -> Self {
40        Self {
41            raw_index: 0,
42            raw_marker: String::new(),
43        }
44    }
45}
46
47fn write_raw_marker(
48    marker: &mut String,
49    writer: &mut dyn ResponseWriter,
50    index: usize,
51    closing: bool,
52) -> Result<()> {
53    let mut reversed_digits = [0_u8; 20];
54    let mut digit_count = 0;
55    let mut remaining = index;
56    loop {
57        reversed_digits[digit_count] = b"0123456789"[remaining % 10];
58        digit_count += 1;
59        remaining /= 10;
60        if remaining == 0 {
61            break;
62        }
63    }
64
65    marker.clear();
66    if marker.capacity() == 0 {
67        marker.reserve(32);
68    }
69    marker.push_str(if closing {
70        RAW_END_PREFIX
71    } else {
72        RAW_START_PREFIX
73    });
74    for index in (0..digit_count).rev() {
75        marker.push(char::from(reversed_digits[index]));
76    }
77    marker.push_str(MARKER_SUFFIX);
78    writer.write(marker)
79}
80
81impl Default for WebUIHydrationPlugin {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl HandlerPlugin for WebUIHydrationPlugin {
88    fn push_scope(&mut self) {}
89
90    fn pop_scope(&mut self) {}
91
92    fn on_binding_start(
93        &mut self,
94        _name: &str,
95        raw: bool,
96        writer: &mut dyn ResponseWriter,
97    ) -> Result<()> {
98        if raw {
99            write_raw_marker(&mut self.raw_marker, writer, self.raw_index, false)
100        } else {
101            Ok(())
102        }
103    }
104
105    fn on_binding_end(
106        &mut self,
107        _name: &str,
108        raw: bool,
109        writer: &mut dyn ResponseWriter,
110    ) -> Result<()> {
111        if raw {
112            write_raw_marker(&mut self.raw_marker, writer, self.raw_index, true)?;
113            self.raw_index += 1;
114        }
115        Ok(())
116    }
117
118    fn on_for_start(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
119        writer.write(REPEAT_START)
120    }
121
122    fn on_for_end(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
123        writer.write(REPEAT_END)
124    }
125
126    fn on_if_start(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
127        writer.write(COND_START)
128    }
129
130    fn on_if_end(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
131        writer.write(COND_END)
132    }
133
134    fn on_repeat_item_start(
135        &mut self,
136        _index: usize,
137        writer: &mut dyn ResponseWriter,
138    ) -> Result<()> {
139        writer.write(REPEAT_ITEM)
140    }
141
142    // No end marker needed — the next <!--wi--> or <!--/wr--> serves as the boundary.
143    fn on_repeat_item_end(
144        &mut self,
145        _index: usize,
146        _writer: &mut dyn ResponseWriter,
147    ) -> Result<()> {
148        Ok(())
149    }
150
151    fn on_element_data(&mut self, _data: &[u8], _writer: &mut dyn ResponseWriter) -> Result<()> {
152        Ok(())
153    }
154
155    /// Emit legacy non-split WebUI component templates inside a single
156    /// `<script>` tag for fallback paths.
157    ///
158    /// NOTE: When `collect_template_payloads` returns `Some(...)`, `lib.rs`
159    /// emits template metadata in an `application/json` block and this method
160    /// is **not** called. It remains for non-WebUI plugin fallback paths.
161    fn emit_templates(
162        &self,
163        protocol: &WebUIProtocol,
164        components: &[String],
165        nonce: Option<&str>,
166        writer: &mut dyn ResponseWriter,
167    ) -> Result<()> {
168        webui_emit_templates(
169            protocol,
170            components.iter().map(String::as_str),
171            nonce,
172            writer,
173        )
174    }
175
176    fn emit_templates_slice(
177        &self,
178        protocol: &WebUIProtocol,
179        tags: &[&str],
180        nonce: Option<&str>,
181        writer: &mut dyn ResponseWriter,
182    ) -> Result<()> {
183        webui_emit_templates(protocol, tags.iter().copied(), nonce, writer)
184    }
185
186    /// Collect split WebUI template payloads for SSR bootstrap emission.
187    fn collect_template_payloads<'a>(
188        &self,
189        protocol: &'a WebUIProtocol,
190        components: &[String],
191    ) -> Option<Vec<super::WebUiTemplatePayload<'a>>> {
192        webui_collect_payloads(protocol, components.iter().map(String::as_str))
193    }
194
195    fn collect_template_payloads_slice<'a>(
196        &self,
197        protocol: &'a WebUIProtocol,
198        tags: &[&str],
199    ) -> Option<Vec<super::WebUiTemplatePayload<'a>>> {
200        webui_collect_payloads(protocol, tags.iter().copied())
201    }
202
203    fn emit_bootstrap_extension(
204        &self,
205        context: BootstrapExtensionContext<'_>,
206        writer: &mut dyn ResponseWriter,
207    ) -> Result<()> {
208        webui_emit_bootstrap_fns(context.payloads, context.nonce, writer)
209    }
210
211    fn emit_bootstrap_extension_payloads(
212        &self,
213        payloads: &[super::WebUiTemplatePayload<'_>],
214        nonce: Option<&str>,
215        writer: &mut dyn ResponseWriter,
216    ) -> Result<()> {
217        webui_emit_bootstrap_fns(payloads, nonce, writer)
218    }
219}
220
221/// Emit non-split WebUI component templates inside a single `<script>` tag.
222///
223/// Shared by the ordinary slice-based path and the `&[&str]`-based streaming
224/// path; the lookup-key lifetime `'b` is independent of the protocol so both
225/// callers pass borrowed tags without cloning.
226fn webui_emit_templates<'b>(
227    protocol: &WebUIProtocol,
228    tags: impl Iterator<Item = &'b str>,
229    nonce: Option<&str>,
230    writer: &mut dyn ResponseWriter,
231) -> Result<()> {
232    let mut templates: Vec<&str> = Vec::new();
233
234    for name in tags {
235        if let Some(template) = protocol
236            .components
237            .get(name)
238            .map(|component| component.template.as_str())
239            .filter(|t| !t.is_empty())
240        {
241            templates.push(template);
242        }
243    }
244
245    if templates.is_empty() {
246        return Ok(());
247    }
248
249    if let Some(nonce) = nonce {
250        writer.write("<script nonce=\"")?;
251        writer.write(nonce)?;
252        writer.write("\">\n")?;
253    } else {
254        writer.write("<script>\n")?;
255    }
256    for tmpl in &templates {
257        writer.write(tmpl)?;
258    }
259    writer.write("</script>\n")?;
260
261    Ok(())
262}
263
264/// Collect split WebUI template payloads for the given component tags.
265///
266/// Returned payloads borrow the protocol (`'a`); the lookup-key lifetime `'b`
267/// is independent so both the ordinary slice and streaming callers share this helper.
268fn webui_collect_payloads<'a, 'b>(
269    protocol: &'a WebUIProtocol,
270    tags: impl Iterator<Item = &'b str>,
271) -> Option<Vec<super::WebUiTemplatePayload<'a>>> {
272    let mut templates: Vec<super::WebUiTemplatePayload<'a>> = Vec::new();
273    for name in tags {
274        if let Some((tag_name, component)) = protocol.components.get_key_value(name) {
275            if !component.template_json.is_empty() {
276                templates.push(super::WebUiTemplatePayload {
277                    tag_name: tag_name.as_str(),
278                    template_json: component.template_json.as_str(),
279                    template_functions: component.template_functions.as_str(),
280                });
281            }
282        }
283    }
284    if templates.is_empty() {
285        None
286    } else {
287        Some(templates)
288    }
289}
290
291/// Emit the WebUI `templateFns` executable side-channel for payloads that carry
292/// component-local condition closures. Shared by the ordinary and streaming
293/// bootstrap-extension hooks.
294fn webui_emit_bootstrap_fns(
295    payloads: &[super::WebUiTemplatePayload<'_>],
296    nonce: Option<&str>,
297    writer: &mut dyn ResponseWriter,
298) -> Result<()> {
299    let has_functions = payloads
300        .iter()
301        .any(|payload| !payload.template_functions.is_empty());
302    if !has_functions {
303        return Ok(());
304    }
305
306    if let Some(nonce) = nonce {
307        writer.write("<script nonce=\"")?;
308        writer.write(nonce)?;
309        writer.write("\">")?;
310    } else {
311        writer.write("<script>")?;
312    }
313    writer.write("(function(){var w=window.__webui||(window.__webui={});")?;
314    writer.write("var f=w.templateFns||(w.templateFns={});")?;
315    // Request-local scratch reused across every tag_name serialized in this
316    // loop; dropped when the emission returns.
317    let mut json_scratch = Vec::new();
318    for payload in payloads {
319        if payload.template_functions.is_empty() {
320            continue;
321        }
322        writer.write("f[")?;
323        crate::write_script_safe_json(writer, &mut json_scratch, payload.tag_name)?;
324        writer.write("]=")?;
325        writer.write(payload.template_functions)?;
326        writer.write(";")?;
327    }
328    writer.write("})();</script>\n")
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    struct TestWriter {
336        output: String,
337    }
338
339    impl TestWriter {
340        fn new() -> Self {
341            Self {
342                output: String::new(),
343            }
344        }
345    }
346
347    impl ResponseWriter for TestWriter {
348        fn write(&mut self, content: &str) -> Result<()> {
349            self.output.push_str(content);
350            Ok(())
351        }
352        fn end(&mut self) -> Result<()> {
353            Ok(())
354        }
355    }
356
357    #[test]
358    fn test_default_creates_instance() {
359        let _plugin = WebUIHydrationPlugin::default();
360    }
361
362    #[test]
363    fn test_signal_binding_emits_no_output() {
364        let mut plugin = WebUIHydrationPlugin::new();
365        let mut writer = TestWriter::new();
366        plugin.on_binding_start("x", false, &mut writer).unwrap();
367        plugin.on_binding_end("x", false, &mut writer).unwrap();
368        assert_eq!(
369            writer.output, "",
370            "signal binding hooks must not emit output"
371        );
372    }
373
374    #[test]
375    fn test_raw_signal_binding_emits_range_markers() {
376        let mut plugin = WebUIHydrationPlugin::new();
377        let mut writer = TestWriter::new();
378        plugin.on_binding_start("html", true, &mut writer).unwrap();
379        writer.write("<b>trusted</b>").unwrap();
380        plugin.on_binding_end("html", true, &mut writer).unwrap();
381        assert_eq!(writer.output, "<!--w0--><b>trusted</b><!--/w0-->");
382    }
383
384    #[test]
385    fn test_for_loop_emits_repeat_markers() {
386        let mut plugin = WebUIHydrationPlugin::new();
387        let mut writer = TestWriter::new();
388        plugin.on_for_start("items", &mut writer).unwrap();
389        plugin.on_repeat_item_start(0, &mut writer).unwrap();
390        plugin.push_scope();
391        writer.write("<div>A</div>").unwrap();
392        plugin.pop_scope();
393        plugin.on_repeat_item_end(0, &mut writer).unwrap();
394        plugin.on_repeat_item_start(1, &mut writer).unwrap();
395        plugin.push_scope();
396        writer.write("<div>B</div>").unwrap();
397        plugin.pop_scope();
398        plugin.on_repeat_item_end(1, &mut writer).unwrap();
399        plugin.on_for_end("items", &mut writer).unwrap();
400        assert_eq!(
401            writer.output,
402            "<!--wr--><!--wi--><div>A</div><!--wi--><div>B</div><!--/wr-->"
403        );
404    }
405
406    #[test]
407    fn test_empty_for_loop_emits_boundary_markers() {
408        let mut plugin = WebUIHydrationPlugin::new();
409        let mut writer = TestWriter::new();
410        plugin.on_for_start("items", &mut writer).unwrap();
411        plugin.on_for_end("items", &mut writer).unwrap();
412        assert_eq!(writer.output, "<!--wr--><!--/wr-->");
413    }
414
415    #[test]
416    fn test_if_true_emits_cond_markers() {
417        let mut plugin = WebUIHydrationPlugin::new();
418        let mut writer = TestWriter::new();
419        plugin.on_if_start("show", &mut writer).unwrap();
420        plugin.push_scope();
421        writer.write("<p>visible</p>").unwrap();
422        plugin.pop_scope();
423        plugin.on_if_end("show", &mut writer).unwrap();
424        assert_eq!(writer.output, "<!--wc--><p>visible</p><!--/wc-->");
425    }
426
427    #[test]
428    fn test_if_false_emits_empty_cond_markers() {
429        let mut plugin = WebUIHydrationPlugin::new();
430        let mut writer = TestWriter::new();
431        plugin.on_if_start("show", &mut writer).unwrap();
432        // condition is false — no content rendered
433        plugin.on_if_end("show", &mut writer).unwrap();
434        assert_eq!(writer.output, "<!--wc--><!--/wc-->");
435    }
436
437    #[test]
438    fn test_nested_repeat_inside_conditional() {
439        let mut plugin = WebUIHydrationPlugin::new();
440        let mut writer = TestWriter::new();
441        plugin.on_if_start("show", &mut writer).unwrap();
442        plugin.push_scope();
443        plugin.on_for_start("items", &mut writer).unwrap();
444        plugin.on_repeat_item_start(0, &mut writer).unwrap();
445        plugin.push_scope();
446        writer.write("<li>X</li>").unwrap();
447        plugin.pop_scope();
448        plugin.on_repeat_item_end(0, &mut writer).unwrap();
449        plugin.on_for_end("items", &mut writer).unwrap();
450        plugin.pop_scope();
451        plugin.on_if_end("show", &mut writer).unwrap();
452        assert_eq!(
453            writer.output,
454            "<!--wc--><!--wr--><!--wi--><li>X</li><!--/wr--><!--/wc-->"
455        );
456    }
457
458    #[test]
459    fn test_element_data_emits_no_output() {
460        let mut plugin = WebUIHydrationPlugin::new();
461        let mut writer = TestWriter::new();
462        plugin.push_scope();
463        let data = 3u32.to_le_bytes();
464        plugin.on_element_data(&data, &mut writer).unwrap();
465        assert_eq!(writer.output, "", "element data must not emit output");
466    }
467
468    #[test]
469    fn test_scope_push_pop_is_noop() {
470        let mut plugin = WebUIHydrationPlugin::new();
471        plugin.push_scope();
472        plugin.push_scope();
473        plugin.pop_scope();
474        plugin.pop_scope();
475    }
476
477    #[test]
478    fn test_route_component_state_emits_no_output() {
479        let plugin = WebUIHydrationPlugin::new();
480        let mut writer = TestWriter::new();
481        let state = serde_json::json!({
482            "title": "Hello",
483            "cartOpen": true,
484            "items": [{"name": "A&B"}],
485        });
486        plugin
487            .write_route_component_state(&state, &mut writer)
488            .unwrap();
489        assert_eq!(
490            writer.output, "",
491            "route component state must not emit output"
492        );
493    }
494
495    #[test]
496    fn test_full_lifecycle_with_markers() {
497        let mut plugin = WebUIHydrationPlugin::new();
498        let mut writer = TestWriter::new();
499
500        // Simulate a component with a signal, repeat, and conditional.
501        plugin.push_scope();
502        // Signal binding — no markers
503        plugin.on_binding_start("a", false, &mut writer).unwrap();
504        writer.write("hello").unwrap();
505        plugin.on_binding_end("a", false, &mut writer).unwrap();
506        // For-loop — markers
507        plugin.on_for_start("list", &mut writer).unwrap();
508        plugin.on_repeat_item_start(0, &mut writer).unwrap();
509        plugin.push_scope();
510        writer.write("<x-item>1</x-item>").unwrap();
511        plugin.pop_scope();
512        plugin.on_repeat_item_end(0, &mut writer).unwrap();
513        plugin.on_for_end("list", &mut writer).unwrap();
514        // Conditional — markers
515        plugin.on_if_start("flag", &mut writer).unwrap();
516        plugin.push_scope();
517        writer.write("<p>yes</p>").unwrap();
518        plugin.pop_scope();
519        plugin.on_if_end("flag", &mut writer).unwrap();
520        plugin.pop_scope();
521
522        assert_eq!(
523            writer.output,
524            "hello<!--wr--><!--wi--><x-item>1</x-item><!--/wr--><!--wc--><p>yes</p><!--/wc-->"
525        );
526    }
527
528    fn iife_template(tag: &str, body: &str) -> String {
529        format!("(function(){{var w=(window.__webui||(window.__webui={{}})).templates||(window.__webui.templates={{}});w['{tag}']={{{body}}}}})();\n")
530    }
531
532    #[test]
533    fn test_emit_templates_only_emits_provided_components() {
534        let mut writer = TestWriter::new();
535
536        let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
537        protocol
538            .components
539            .entry("comp-a".to_string())
540            .or_default()
541            .template = iife_template("comp-a", "h:\"a\"");
542        protocol
543            .components
544            .entry("comp-b".to_string())
545            .or_default()
546            .template = iife_template("comp-b", "h:\"b\"");
547        protocol
548            .components
549            .entry("comp-c".to_string())
550            .or_default()
551            .template = iife_template("comp-c", "h:\"c\"");
552
553        let components = vec!["comp-a".to_string()];
554
555        let plugin = WebUIHydrationPlugin::new();
556        plugin
557            .emit_templates(&protocol, &components, None, &mut writer)
558            .unwrap();
559
560        assert!(
561            writer.output.contains("comp-a"),
562            "rendered component should be emitted: {}",
563            writer.output
564        );
565        assert!(
566            !writer.output.contains("comp-b"),
567            "non-rendered component should NOT be emitted: {}",
568            writer.output
569        );
570        assert!(
571            !writer.output.contains("comp-c"),
572            "non-rendered component should NOT be emitted: {}",
573            writer.output
574        );
575    }
576
577    #[test]
578    fn test_emit_templates_empty_set() {
579        let mut writer = TestWriter::new();
580        let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
581        protocol
582            .components
583            .entry("comp-a".to_string())
584            .or_default()
585            .template = iife_template("comp-a", "h:\"a\"");
586        let components: Vec<String> = Vec::new();
587        let plugin = WebUIHydrationPlugin::new();
588        plugin
589            .emit_templates(&protocol, &components, None, &mut writer)
590            .unwrap();
591        assert_eq!(writer.output, "", "empty set should emit nothing");
592    }
593
594    #[test]
595    fn test_on_render_complete_consolidates_into_single_script() {
596        let mut writer = TestWriter::new();
597
598        let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
599        protocol
600            .components
601            .entry("comp-a".to_string())
602            .or_default()
603            .template = iife_template("comp-a", "h:\"a\"");
604        protocol
605            .components
606            .entry("comp-b".to_string())
607            .or_default()
608            .template = iife_template("comp-b", "h:\"b\"");
609
610        let rendered = vec!["comp-a".to_string(), "comp-b".to_string()];
611
612        let plugin = WebUIHydrationPlugin::new();
613        plugin
614            .emit_templates(&protocol, &rendered, None, &mut writer)
615            .unwrap();
616
617        let script_count = writer.output.matches("<script").count();
618        assert_eq!(
619            script_count, 1,
620            "should emit exactly one <script> tag, got {script_count}: {}",
621            writer.output
622        );
623        assert!(writer.output.contains("comp-a"));
624        assert!(writer.output.contains("comp-b"));
625    }
626
627    #[test]
628    fn test_on_render_complete_skips_empty_template() {
629        let mut writer = TestWriter::new();
630        let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
631        protocol
632            .components
633            .entry("comp-a".to_string())
634            .or_default()
635            .template = String::new();
636        let rendered = vec!["comp-a".to_string()];
637        let plugin = WebUIHydrationPlugin::new();
638        plugin
639            .emit_templates(&protocol, &rendered, None, &mut writer)
640            .unwrap();
641        assert_eq!(writer.output, "", "empty template should not be emitted");
642    }
643
644    #[test]
645    fn test_on_render_complete_unknown_component() {
646        let mut writer = TestWriter::new();
647        let protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
648        let rendered = vec!["nonexistent-comp".to_string()];
649        let plugin = WebUIHydrationPlugin::new();
650        plugin
651            .emit_templates(&protocol, &rendered, None, &mut writer)
652            .unwrap();
653        assert_eq!(
654            writer.output, "",
655            "unknown component should not cause error"
656        );
657    }
658
659    // ── Integration test (full render cycle with WebUIHandler) ──────────
660
661    use std::collections::HashMap;
662    use webui_protocol::{ConditionExpr, FragmentList, WebUIFragment, WebUIProtocol};
663    use webui_test_utils::test_json;
664
665    use crate::{RenderOptions, WebUIHandler};
666
667    fn render_with_webui_plugin(protocol: &WebUIProtocol, state: &serde_json::Value) -> String {
668        let mut writer = TestWriter::new();
669        let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
670        handler
671            .handle(
672                protocol,
673                state,
674                &RenderOptions::new("index.html", "/"),
675                &mut writer,
676            )
677            .unwrap();
678        writer.output
679    }
680
681    #[test]
682    fn test_handler_emits_hydration_markers_for_loop_and_if() {
683        // Build a protocol with a for-loop (2 items) and an if-condition.
684        let mut fragments = HashMap::new();
685        fragments.insert(
686            "index.html".to_string(),
687            FragmentList {
688                fragments: vec![
689                    WebUIFragment::for_loop("item", "items", "for-body"),
690                    WebUIFragment::if_cond(ConditionExpr::identifier("show"), "if-body"),
691                    WebUIFragment::signal("html", true),
692                ],
693                contains_boundary: false,
694            },
695        );
696        fragments.insert(
697            "for-body".to_string(),
698            FragmentList {
699                fragments: vec![WebUIFragment::signal("item", false)],
700                contains_boundary: false,
701            },
702        );
703        fragments.insert(
704            "if-body".to_string(),
705            FragmentList {
706                fragments: vec![WebUIFragment::raw("<span>yes</span>")],
707                contains_boundary: false,
708            },
709        );
710        let protocol = WebUIProtocol::new(fragments);
711        let state = test_json!({"items": ["a", "b"], "show": true, "html": "<i>raw</i>"});
712        let output = render_with_webui_plugin(&protocol, &state);
713
714        // For-loop markers
715        assert!(
716            output.contains("<!--wr-->"),
717            "Expected repeat-start marker, got: {output}"
718        );
719        assert!(
720            output.contains("<!--/wr-->"),
721            "Expected repeat-end marker, got: {output}"
722        );
723        assert!(
724            output.contains("<!--wi-->"),
725            "Expected repeat-item marker, got: {output}"
726        );
727        // Each item should produce a <!--wi--> marker
728        assert_eq!(
729            output.matches("<!--wi-->").count(),
730            2,
731            "Expected 2 repeat-item markers, got: {output}"
732        );
733
734        // If-condition markers
735        assert!(
736            output.contains("<!--wc-->"),
737            "Expected cond-start marker, got: {output}"
738        );
739        assert!(
740            output.contains("<!--/wc-->"),
741            "Expected cond-end marker, got: {output}"
742        );
743
744        // Content is rendered
745        assert!(
746            output.contains("a"),
747            "Expected for-loop items in output, got: {output}"
748        );
749        assert!(
750            output.contains("b"),
751            "Expected for-loop items in output, got: {output}"
752        );
753        assert!(
754            output.contains("<span>yes</span>"),
755            "Expected if-condition body, got: {output}"
756        );
757        assert!(
758            output.contains("<!--w0--><i>raw</i><!--/w0-->"),
759            "Expected bounded raw HTML markers, got: {output}"
760        );
761    }
762
763    #[test]
764    fn test_handler_keeps_raw_text_signal_marker_free() {
765        let mut fragments = HashMap::new();
766        fragments.insert(
767            "index.html".to_string(),
768            FragmentList {
769                fragments: vec![
770                    WebUIFragment::raw("<style>:root{"),
771                    WebUIFragment::raw_text_signal("tokens", true),
772                    WebUIFragment::raw("}</style>"),
773                ],
774                contains_boundary: false,
775            },
776        );
777        let protocol = WebUIProtocol::new(fragments);
778        let state = test_json!({"tokens": "--accent:red"});
779        let output = render_with_webui_plugin(&protocol, &state);
780
781        assert!(output.contains("<style>:root{--accent:red}</style>"));
782        assert!(!output.contains("<!--w0-->"));
783        assert!(!output.contains("<!--/w0-->"));
784    }
785}