Skip to main content

webui_handler/plugin/
fast.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! FAST Hydration plugin for the WebUI handler.
5//!
6//! Injects HTML comment markers and data attributes that enable client-side
7//! FAST-HTML to locate and re-hydrate server-rendered dynamic content.
8//!
9//! ## Comment Format
10//!
11//! - **Binding start**: `<!--fe-b$$start$$INDEX$$NAME$$fe-b-->`
12//! - **Binding end**: `<!--fe-b$$end$$INDEX$$NAME$$fe-b-->`
13//! - **Repeat item start**: `<!--fe-repeat$$start$$INDEX$$fe-repeat-->`
14//! - **Repeat item end**: `<!--fe-repeat$$end$$INDEX$$fe-repeat-->`
15//! - **Attribute single**: ` data-fe-b-INDEX`
16//! - **Attribute multi**: ` data-fe-c-INDEX-COUNT`
17//!
18//! ## Scoping
19//!
20//! Each component and for-loop item gets its own scope with an independent
21//! binding counter starting from 0. This matches the C++ prototype behavior.
22
23use super::HandlerPlugin;
24use crate::{ResponseWriter, Result};
25use std::fmt::Write;
26
27// Comment format constants
28const BINDING_START_PREFIX: &str = "<!--fe-b$$start$$";
29const BINDING_END_PREFIX: &str = "<!--fe-b$$end$$";
30const BINDING_SUFFIX: &str = "$$fe-b-->";
31const SEPARATOR: &str = "$$";
32const REPEAT_START_PREFIX: &str = "<!--fe-repeat$$start$$";
33const REPEAT_END_PREFIX: &str = "<!--fe-repeat$$end$$";
34const REPEAT_SUFFIX: &str = "$$fe-repeat-->";
35const ATTR_SINGLE_PREFIX: &str = " data-fe-b-";
36const ATTR_MULTI_PREFIX: &str = " data-fe-c-";
37
38/// FAST Hydration handler plugin.
39///
40/// Emits HTML comment markers around dynamic bindings so that FAST-HTML
41/// can re-hydrate server-rendered content on the client side.
42///
43/// The root scope is disabled (no markers) — hydration only activates in
44/// child scopes (components, for-loop items, if-condition bodies).
45/// This matches the C++ and JS prototype behavior.
46pub struct FastHydrationPlugin {
47    /// Stack of local binding counters (one per scope).
48    /// The bottom of the stack is the root scope (disabled).
49    scopes: Vec<usize>,
50    /// Stack of binding indices for matching start/end pairs.
51    binding_stack: Vec<usize>,
52    /// Reusable buffer for formatting markers without allocation.
53    buffer: String,
54}
55
56impl FastHydrationPlugin {
57    /// Create a new FAST hydration plugin.
58    /// The initial root scope is disabled — markers only emitted in child scopes.
59    #[must_use]
60    pub fn new() -> Self {
61        Self {
62            // Root scope (index 0) is disabled — only scopes.len() > 1 are active
63            scopes: vec![0],
64            binding_stack: Vec::with_capacity(8),
65            buffer: String::with_capacity(64),
66        }
67    }
68
69    /// Whether the current scope is active (not the root scope).
70    fn is_active(&self) -> bool {
71        self.scopes.len() > 1
72    }
73
74    /// Get the next binding index in the current scope, advancing the counter.
75    fn next_index(&mut self) -> usize {
76        if let Some(counter) = self.scopes.last_mut() {
77            let index = *counter;
78            *counter += 1;
79            index
80        } else {
81            0
82        }
83    }
84
85    /// Get the next binding index, advancing the counter by `count`.
86    fn next_index_n(&mut self, count: u32) -> usize {
87        if let Some(counter) = self.scopes.last_mut() {
88            let index = *counter;
89            *counter += count as usize;
90            index
91        } else {
92            0
93        }
94    }
95
96    /// Build a binding comment into the reusable buffer.
97    fn build_binding_comment(&mut self, prefix: &str, index: usize, name: &str) {
98        self.buffer.clear();
99        self.buffer.push_str(prefix);
100        let _ = write!(self.buffer, "{}", index);
101        self.buffer.push_str(SEPARATOR);
102        self.buffer.push_str(name);
103        self.buffer.push_str(BINDING_SUFFIX);
104    }
105
106    /// Build a repeat comment into the reusable buffer.
107    fn build_repeat_comment(&mut self, prefix: &str, index: usize) {
108        self.buffer.clear();
109        self.buffer.push_str(prefix);
110        let _ = write!(self.buffer, "{}", index);
111        self.buffer.push_str(REPEAT_SUFFIX);
112    }
113
114    /// Build an attribute binding marker into the reusable buffer.
115    fn build_attribute_marker(&mut self, binding_index: usize, count: u32) {
116        self.buffer.clear();
117        if count == 1 {
118            self.buffer.push_str(ATTR_SINGLE_PREFIX);
119            let _ = write!(self.buffer, "{}", binding_index);
120        } else {
121            self.buffer.push_str(ATTR_MULTI_PREFIX);
122            let _ = write!(self.buffer, "{}-{}", binding_index, count);
123        }
124    }
125}
126
127impl Default for FastHydrationPlugin {
128    fn default() -> Self {
129        Self::new()
130    }
131}
132
133impl HandlerPlugin for FastHydrationPlugin {
134    fn push_scope(&mut self) {
135        self.scopes.push(0);
136    }
137
138    fn pop_scope(&mut self) {
139        self.scopes.pop();
140    }
141
142    fn on_binding_start(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
143        if !self.is_active() {
144            return Ok(());
145        }
146        let index = self.next_index();
147        self.binding_stack.push(index);
148        self.build_binding_comment(BINDING_START_PREFIX, index, name);
149        writer.write(&self.buffer)
150    }
151
152    fn on_binding_end(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
153        if !self.is_active() {
154            return Ok(());
155        }
156        let index = self.binding_stack.pop().unwrap_or(0);
157        self.build_binding_comment(BINDING_END_PREFIX, index, name);
158        writer.write(&self.buffer)
159    }
160
161    fn on_repeat_item_start(
162        &mut self,
163        index: usize,
164        writer: &mut dyn ResponseWriter,
165    ) -> Result<()> {
166        if !self.is_active() {
167            return Ok(());
168        }
169        self.build_repeat_comment(REPEAT_START_PREFIX, index);
170        writer.write(&self.buffer)
171    }
172
173    fn on_repeat_item_end(&mut self, index: usize, writer: &mut dyn ResponseWriter) -> Result<()> {
174        if !self.is_active() {
175            return Ok(());
176        }
177        self.build_repeat_comment(REPEAT_END_PREFIX, index);
178        writer.write(&self.buffer)
179    }
180
181    fn on_plugin_data(&mut self, data: &[u8], writer: &mut dyn ResponseWriter) -> Result<()> {
182        if !self.is_active() {
183            return Ok(());
184        }
185        // FAST hydration protocol: data is a u32 LE attribute count
186        if data.len() >= 4 {
187            let count = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
188            if count > 0 {
189                let binding_index = self.next_index_n(count);
190                self.build_attribute_marker(binding_index, count);
191                writer.write(&self.buffer)?;
192            }
193        }
194        Ok(())
195    }
196
197    fn on_render_complete(
198        &mut self,
199        protocol: &webui_protocol::WebUIProtocol,
200        rendered_components: &std::collections::HashSet<String>,
201        writer: &mut dyn ResponseWriter,
202    ) -> Result<()> {
203        // Emit f-templates for only the components that were actually rendered.
204        // CSS module definitions are NOT included here — the handler already
205        // emitted them during SSR via process_component().
206        for name in rendered_components {
207            if let Some(tmpl) = protocol
208                .components
209                .get(name)
210                .map(|c| c.template.as_str())
211                .filter(|s| !s.is_empty())
212            {
213                writer.write(tmpl)?;
214            }
215        }
216        Ok(())
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    struct TestWriter {
225        output: String,
226    }
227
228    impl TestWriter {
229        fn new() -> Self {
230            Self {
231                output: String::new(),
232            }
233        }
234    }
235
236    impl ResponseWriter for TestWriter {
237        fn write(&mut self, content: &str) -> Result<()> {
238            self.output.push_str(content);
239            Ok(())
240        }
241        fn end(&mut self) -> Result<()> {
242            Ok(())
243        }
244    }
245
246    #[test]
247    fn test_root_scope_disabled() {
248        let mut plugin = FastHydrationPlugin::new();
249        let mut writer = TestWriter::new();
250        // Root scope should not emit markers
251        plugin.on_binding_start("x", &mut writer).unwrap();
252        plugin.on_binding_end("x", &mut writer).unwrap();
253        plugin.on_repeat_item_start(0, &mut writer).unwrap();
254        plugin.on_repeat_item_end(0, &mut writer).unwrap();
255        let data = 3u32.to_le_bytes();
256        plugin.on_plugin_data(&data, &mut writer).unwrap();
257        assert_eq!(writer.output, "");
258    }
259
260    #[test]
261    fn test_binding_start_format() {
262        let mut plugin = FastHydrationPlugin::new();
263        plugin.push_scope();
264        let mut writer = TestWriter::new();
265        plugin.on_binding_start("userName", &mut writer).unwrap();
266        assert_eq!(writer.output, "<!--fe-b$$start$$0$$userName$$fe-b-->");
267    }
268
269    #[test]
270    fn test_binding_end_format() {
271        let mut plugin = FastHydrationPlugin::new();
272        plugin.push_scope();
273        let mut writer = TestWriter::new();
274        plugin.on_binding_start("userName", &mut writer).unwrap();
275        writer.output.clear();
276        plugin.on_binding_end("userName", &mut writer).unwrap();
277        assert_eq!(writer.output, "<!--fe-b$$end$$0$$userName$$fe-b-->");
278    }
279
280    #[test]
281    fn test_binding_index_increments() {
282        let mut plugin = FastHydrationPlugin::new();
283        plugin.push_scope();
284        let mut writer = TestWriter::new();
285        plugin.on_binding_start("a", &mut writer).unwrap();
286        plugin.on_binding_end("a", &mut writer).unwrap();
287        writer.output.clear();
288        plugin.on_binding_start("b", &mut writer).unwrap();
289        assert_eq!(writer.output, "<!--fe-b$$start$$1$$b$$fe-b-->");
290    }
291
292    #[test]
293    fn test_scope_resets_counter() {
294        let mut plugin = FastHydrationPlugin::new();
295        let mut writer = TestWriter::new();
296        // Push first active scope (root is disabled)
297        plugin.push_scope();
298        // Active scope: index 0
299        plugin.on_binding_start("a", &mut writer).unwrap();
300        plugin.on_binding_end("a", &mut writer).unwrap();
301        // Push child scope: counter resets to 0
302        plugin.push_scope();
303        writer.output.clear();
304        plugin.on_binding_start("b", &mut writer).unwrap();
305        assert_eq!(writer.output, "<!--fe-b$$start$$0$$b$$fe-b-->");
306        plugin.on_binding_end("b", &mut writer).unwrap();
307        // Pop child scope: back to parent (counter was at 1)
308        plugin.pop_scope();
309        writer.output.clear();
310        plugin.on_binding_start("c", &mut writer).unwrap();
311        assert_eq!(writer.output, "<!--fe-b$$start$$1$$c$$fe-b-->");
312    }
313
314    #[test]
315    fn test_repeat_item_markers() {
316        let mut plugin = FastHydrationPlugin::new();
317        plugin.push_scope();
318        let mut writer = TestWriter::new();
319        plugin.on_repeat_item_start(0, &mut writer).unwrap();
320        assert_eq!(writer.output, "<!--fe-repeat$$start$$0$$fe-repeat-->");
321        writer.output.clear();
322        plugin.on_repeat_item_end(0, &mut writer).unwrap();
323        assert_eq!(writer.output, "<!--fe-repeat$$end$$0$$fe-repeat-->");
324    }
325
326    #[test]
327    fn test_attribute_binding_single() {
328        let mut plugin = FastHydrationPlugin::new();
329        plugin.push_scope();
330        let mut writer = TestWriter::new();
331        let data = 1u32.to_le_bytes();
332        plugin.on_plugin_data(&data, &mut writer).unwrap();
333        assert_eq!(writer.output, " data-fe-b-0");
334    }
335
336    #[test]
337    fn test_attribute_binding_multi() {
338        let mut plugin = FastHydrationPlugin::new();
339        plugin.push_scope();
340        let mut writer = TestWriter::new();
341        let data = 3u32.to_le_bytes();
342        plugin.on_plugin_data(&data, &mut writer).unwrap();
343        assert_eq!(writer.output, " data-fe-c-0-3");
344    }
345
346    #[test]
347    fn test_attribute_binding_zero_count_no_output() {
348        let mut plugin = FastHydrationPlugin::new();
349        plugin.push_scope();
350        let mut writer = TestWriter::new();
351        let data = 0u32.to_le_bytes();
352        plugin.on_plugin_data(&data, &mut writer).unwrap();
353        assert_eq!(writer.output, "");
354    }
355
356    #[test]
357    fn test_attribute_binding_advances_counter() {
358        let mut plugin = FastHydrationPlugin::new();
359        plugin.push_scope();
360        let mut writer = TestWriter::new();
361        // 3 attributes → counter goes from 0 to 3
362        let data = 3u32.to_le_bytes();
363        plugin.on_plugin_data(&data, &mut writer).unwrap();
364        // Next binding should be at index 3
365        writer.output.clear();
366        plugin.on_binding_start("x", &mut writer).unwrap();
367        assert_eq!(writer.output, "<!--fe-b$$start$$3$$x$$fe-b-->");
368    }
369
370    #[test]
371    fn test_nested_scopes_independent_counters() {
372        let mut plugin = FastHydrationPlugin::new();
373        let mut writer = TestWriter::new();
374        // Push first active scope (root is disabled)
375        plugin.push_scope();
376        // Active scope: binding 0
377        plugin.on_binding_start("root", &mut writer).unwrap();
378        plugin.on_binding_end("root", &mut writer).unwrap();
379        // Component scope
380        plugin.push_scope();
381        // For-loop binding in component: index 0 (new scope)
382        writer.output.clear();
383        plugin.on_binding_start("for-1", &mut writer).unwrap();
384        assert!(writer.output.contains("start$$0$$for-1"));
385        // For-loop item scope
386        plugin.push_scope();
387        writer.output.clear();
388        plugin.on_binding_start("signal", &mut writer).unwrap();
389        assert!(writer.output.contains("start$$0$$signal"));
390        plugin.on_binding_end("signal", &mut writer).unwrap();
391        plugin.pop_scope();
392        plugin.on_binding_end("for-1", &mut writer).unwrap();
393        plugin.pop_scope();
394        // Back to first active scope: counter should be at 1
395        writer.output.clear();
396        plugin.on_binding_start("root2", &mut writer).unwrap();
397        assert!(writer.output.contains("start$$1$$root2"));
398    }
399
400    #[test]
401    fn test_empty_plugin_data_ignored() {
402        let mut plugin = FastHydrationPlugin::new();
403        plugin.push_scope();
404        let mut writer = TestWriter::new();
405        plugin.on_plugin_data(&[], &mut writer).unwrap();
406        assert_eq!(writer.output, "");
407    }
408
409    #[test]
410    fn test_short_plugin_data_ignored() {
411        let mut plugin = FastHydrationPlugin::new();
412        plugin.push_scope();
413        let mut writer = TestWriter::new();
414        plugin.on_plugin_data(&[1, 2], &mut writer).unwrap();
415        assert_eq!(writer.output, "");
416    }
417
418    // ── Integration tests (full render cycles with WebUIHandler) ────────
419
420    use std::collections::HashMap;
421    use webui_protocol::{
422        web_ui_fragment, ConditionExpr, FragmentList, LogicalOperator, WebUIFragment,
423        WebUIFragmentAttribute, WebUIProtocol,
424    };
425    use webui_test_utils::test_json;
426
427    use crate::{RenderOptions, WebUIHandler};
428
429    fn render_with_plugin(
430        protocol: &WebUIProtocol,
431        state: &serde_json::Value,
432        factory: fn() -> Box<dyn HandlerPlugin>,
433    ) -> String {
434        let mut writer = TestWriter::new();
435        let handler = WebUIHandler::with_plugin(factory);
436        handler
437            .handle(
438                protocol,
439                state,
440                &RenderOptions::new("index.html", "/"),
441                &mut writer,
442            )
443            .unwrap();
444        writer.output
445    }
446
447    fn render_no_plugin(protocol: &WebUIProtocol, state: &serde_json::Value) -> String {
448        let mut writer = TestWriter::new();
449        let handler = WebUIHandler::new();
450        handler
451            .handle(
452                protocol,
453                state,
454                &RenderOptions::new("index.html", "/"),
455                &mut writer,
456            )
457            .unwrap();
458        writer.output
459    }
460
461    #[test]
462    fn test_no_plugin_no_markers() {
463        let mut fragments = HashMap::new();
464        fragments.insert(
465            "index.html".to_string(),
466            FragmentList {
467                fragments: vec![
468                    WebUIFragment::raw("<p>"),
469                    WebUIFragment::signal("name", false),
470                    WebUIFragment::raw("</p>"),
471                ],
472            },
473        );
474        let protocol = WebUIProtocol::new(fragments);
475        let state = test_json!({"name": "Alice"});
476        let output = render_no_plugin(&protocol, &state);
477        assert_eq!(output, "<p>Alice</p>");
478        assert!(!output.contains("fe-b"));
479    }
480
481    #[test]
482    fn test_hydration_signal_markers() {
483        let mut fragments = HashMap::new();
484        fragments.insert(
485            "index.html".to_string(),
486            FragmentList {
487                fragments: vec![
488                    WebUIFragment::raw("<p>"),
489                    WebUIFragment::signal("name", false),
490                    WebUIFragment::raw("</p>"),
491                ],
492            },
493        );
494        let protocol = WebUIProtocol::new(fragments);
495        let state = test_json!({"name": "Alice"});
496        let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
497        // Root scope is disabled — no markers at root level
498        assert_eq!(output, "<p>Alice</p>");
499    }
500
501    #[test]
502    fn test_hydration_for_loop_markers() {
503        let mut fragments = HashMap::new();
504        fragments.insert(
505            "index.html".to_string(),
506            FragmentList {
507                fragments: vec![WebUIFragment::for_loop("item", "items", "for-1")],
508            },
509        );
510        fragments.insert(
511            "for-1".to_string(),
512            FragmentList {
513                fragments: vec![WebUIFragment::signal("item", false)],
514            },
515        );
516        let protocol = WebUIProtocol::new(fragments);
517        let state = test_json!({"items": ["a", "b"]});
518        let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
519        // Root scope disabled — no for-loop binding or repeat item markers
520        assert!(!output.contains("$$for-1$$"));
521        assert!(!output.contains("fe-repeat"));
522        // Signal bindings inside each item ARE emitted (for-loop items push scope)
523        let item_pattern = "<!--fe-b$$start$$0$$item$$fe-b-->";
524        assert_eq!(output.matches(item_pattern).count(), 2);
525    }
526
527    #[test]
528    fn test_hydration_if_condition_markers() {
529        let mut fragments = HashMap::new();
530        fragments.insert(
531            "index.html".to_string(),
532            FragmentList {
533                fragments: vec![WebUIFragment::if_cond(
534                    ConditionExpr::identifier("show"),
535                    "if-1",
536                )],
537            },
538        );
539        fragments.insert(
540            "if-1".to_string(),
541            FragmentList {
542                fragments: vec![WebUIFragment::raw("<p>Visible</p>")],
543            },
544        );
545        let protocol = WebUIProtocol::new(fragments);
546
547        // True case — root scope disabled, no markers; content still rendered
548        let state = test_json!({"show": true});
549        let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
550        assert!(output.contains("<p>Visible</p>"));
551        assert!(!output.contains("fe-b"));
552
553        // False case — no content, no markers
554        let state = test_json!({"show": false});
555        let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
556        assert!(!output.contains("<p>Visible</p>"));
557        assert!(!output.contains("fe-b"));
558    }
559
560    #[test]
561    fn test_hydration_component_scope_reset() {
562        let mut fragments = HashMap::new();
563        fragments.insert(
564            "index.html".to_string(),
565            FragmentList {
566                fragments: vec![
567                    WebUIFragment::signal("before", false),
568                    WebUIFragment::component("my-comp"),
569                    WebUIFragment::signal("after", false),
570                ],
571            },
572        );
573        fragments.insert(
574            "my-comp".to_string(),
575            FragmentList {
576                fragments: vec![WebUIFragment::signal("inner", false)],
577            },
578        );
579        let protocol = WebUIProtocol::new(fragments);
580        let state = test_json!({"before": "B", "inner": "I", "after": "A"});
581        let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
582        // Root scope disabled — no markers for root-level signals
583        assert!(!output.contains("$$before$$"));
584        assert!(!output.contains("$$after$$"));
585        // Inner signal in component scope gets markers (index 0)
586        assert!(output.contains("<!--fe-b$$start$$0$$inner$$fe-b-->"));
587        assert!(output.contains("<!--fe-b$$end$$0$$inner$$fe-b-->"));
588    }
589
590    #[test]
591    fn test_hydration_plugin_data_fragment() {
592        let mut fragments = HashMap::new();
593        fragments.insert(
594            "index.html".to_string(),
595            FragmentList {
596                fragments: vec![
597                    WebUIFragment::raw("<div"),
598                    WebUIFragment::attribute("id", "itemId"),
599                    WebUIFragment::attribute("title", "itemTitle"),
600                    WebUIFragment::plugin(2u32.to_le_bytes().to_vec()),
601                    WebUIFragment::raw(">content</div>"),
602                ],
603            },
604        );
605        let protocol = WebUIProtocol::new(fragments);
606        let state = test_json!({"itemId": "42", "itemTitle": "Hello"});
607        let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
608        // Root scope disabled — no plugin data markers
609        assert!(!output.contains("data-fe-c"));
610        assert!(output.contains("id=\"42\""));
611        assert!(output.contains("title=\"Hello\""));
612    }
613
614    #[test]
615    fn test_hydration_no_markers_in_mixed_attribute_value() {
616        // Port of C++ HydrationEnabledDoesNotInsertMarkersIntoMixedAttributeValue
617        let mut fragments = HashMap::new();
618        fragments.insert(
619            "index.html".to_string(),
620            FragmentList {
621                fragments: vec![
622                    WebUIFragment::raw("<my-component"),
623                    WebUIFragment {
624                        fragment: Some(web_ui_fragment::Fragment::Attribute(
625                            WebUIFragmentAttribute {
626                                name: "title".to_string(),
627                                template: "attr-title".to_string(),
628                                attr_start: true,
629                                ..Default::default()
630                            },
631                        )),
632                    },
633                    WebUIFragment::raw(">"),
634                    WebUIFragment::component("my-component"),
635                    WebUIFragment::raw("</my-component>"),
636                ],
637            },
638        );
639        fragments.insert(
640            "attr-title".to_string(),
641            FragmentList {
642                fragments: vec![
643                    WebUIFragment::raw("Hello "),
644                    WebUIFragment::signal("name", false),
645                ],
646            },
647        );
648        fragments.insert(
649            "my-component".to_string(),
650            FragmentList {
651                fragments: vec![
652                    WebUIFragment::raw("<span>"),
653                    WebUIFragment::signal("content", false),
654                    WebUIFragment::raw("</span>"),
655                ],
656            },
657        );
658        let protocol = WebUIProtocol::new(fragments);
659        let state = test_json!({"name": "World", "content": "CONTENT"});
660
661        let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
662
663        // Hydration markers should exist in the output (around component content)
664        assert!(
665            output.contains("<!--fe-b"),
666            "Expected hydration markers in output"
667        );
668
669        // The attribute value must NOT contain hydration markers
670        assert!(
671            output.contains("title=\"Hello World\""),
672            "Expected clean attribute value without markers, got: {output}"
673        );
674
675        // Verify no markers leaked into the attribute
676        let title_start = output.find("title=\"").unwrap();
677        let title_end = output[title_start..].find('"').unwrap()
678            + output[title_start + 7..].find('"').unwrap()
679            + 7;
680        let title_value = &output[title_start..title_start + title_end + 1];
681        assert!(
682            !title_value.contains("fe-b"),
683            "Hydration markers leaked into attribute value: {title_value}"
684        );
685    }
686
687    #[test]
688    fn test_hydration_nested_for_if_streams_full() {
689        // Port of C++ HydrationEnabledWithNestedForAndIfStreams
690        let mut fragments = HashMap::new();
691        fragments.insert(
692            "index.html".to_string(),
693            FragmentList {
694                fragments: vec![WebUIFragment::component("hydratableComponent")],
695            },
696        );
697        fragments.insert(
698            "hydratableComponent".to_string(),
699            FragmentList {
700                fragments: vec![
701                    WebUIFragment::raw("<div>"),
702                    WebUIFragment::signal("title", false),
703                    WebUIFragment::for_loop("category", "categories", "categoryTemplate"),
704                    WebUIFragment::raw("</div>"),
705                ],
706            },
707        );
708        fragments.insert(
709            "categoryTemplate".to_string(),
710            FragmentList {
711                fragments: vec![
712                    WebUIFragment::raw("<section"),
713                    WebUIFragment::attribute("data-category", "category.name"),
714                    WebUIFragment::plugin(1u32.to_le_bytes().to_vec()),
715                    WebUIFragment::raw(">"),
716                    WebUIFragment::signal("category.title", false),
717                    // NodeJS: binary(identifier('category.hasItems'), '&&', identifier('category.alwaysTrue'))
718                    WebUIFragment::if_cond(
719                        ConditionExpr::compound(
720                            ConditionExpr::identifier("category.hasItems"),
721                            LogicalOperator::And,
722                            ConditionExpr::identifier("category.alwaysTrue"),
723                        ),
724                        "itemsTemplate",
725                    ),
726                    WebUIFragment::raw("</section>"),
727                ],
728            },
729        );
730        fragments.insert(
731            "itemsTemplate".to_string(),
732            FragmentList {
733                fragments: vec![
734                    WebUIFragment::raw("<ul>"),
735                    WebUIFragment::for_loop("item", "category.items", "itemTemplate"),
736                    WebUIFragment::raw("</ul>"),
737                ],
738            },
739        );
740        fragments.insert(
741            "itemTemplate".to_string(),
742            FragmentList {
743                fragments: vec![
744                    WebUIFragment::raw("<li"),
745                    WebUIFragment::attribute_template("id", "itemIdAttr"),
746                    WebUIFragment::attribute("data-name", "item.name"),
747                    WebUIFragment::plugin(2u32.to_le_bytes().to_vec()),
748                    WebUIFragment::raw(">"),
749                    WebUIFragment::signal("item.name", false),
750                    WebUIFragment::if_cond(
751                        ConditionExpr::identifier("item.special"),
752                        "specialTemplate",
753                    ),
754                    WebUIFragment::raw("</li>"),
755                ],
756            },
757        );
758        fragments.insert(
759            "itemIdAttr".to_string(),
760            FragmentList {
761                fragments: vec![
762                    WebUIFragment::raw("item-"),
763                    WebUIFragment::signal("item.id", false),
764                ],
765            },
766        );
767        fragments.insert(
768            "specialTemplate".to_string(),
769            FragmentList {
770                fragments: vec![
771                    WebUIFragment::raw(" ("),
772                    WebUIFragment::signal("item.specialText", false),
773                    WebUIFragment::raw(")"),
774                ],
775            },
776        );
777        let protocol = WebUIProtocol::new(fragments);
778        let state = test_json!({
779            "title": "My Store",
780            "categories": [
781                {
782                    "name": "electronics",
783                    "title": "Electronics",
784                    "hasItems": true,
785                    "alwaysTrue": true,
786                    "items": [
787                        {"id": "1", "name": "Laptop", "special": true, "specialText": "On Sale"},
788                        {"id": "2", "name": "Phone", "special": false}
789                    ]
790                },
791                {"name": "books", "title": "Books", "hasItems": false},
792                {"name": "toys", "title": "Toys", "hasItems": true, "alwaysTrue": true, "items": []}
793            ]
794        });
795
796        let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
797
798        let expected = "\
799            <div>\
800            <!--fe-b$$start$$0$$title$$fe-b-->My Store<!--fe-b$$end$$0$$title$$fe-b-->\
801            <!--fe-b$$start$$1$$categoryTemplate$$fe-b-->\
802            <!--fe-repeat$$start$$0$$fe-repeat-->\
803            <section data-category=\"electronics\" data-fe-b-0>\
804            <!--fe-b$$start$$1$$category.title$$fe-b-->Electronics<!--fe-b$$end$$1$$category.title$$fe-b-->\
805            <!--fe-b$$start$$2$$itemsTemplate$$fe-b-->\
806            <ul>\
807            <!--fe-b$$start$$0$$itemTemplate$$fe-b-->\
808            <!--fe-repeat$$start$$0$$fe-repeat-->\
809            <li id=\"item-1\" data-name=\"Laptop\" data-fe-c-0-2>\
810            <!--fe-b$$start$$2$$item.name$$fe-b-->Laptop<!--fe-b$$end$$2$$item.name$$fe-b-->\
811            <!--fe-b$$start$$3$$specialTemplate$$fe-b--> \
812            (<!--fe-b$$start$$0$$item.specialText$$fe-b-->On Sale<!--fe-b$$end$$0$$item.specialText$$fe-b-->)\
813            <!--fe-b$$end$$3$$specialTemplate$$fe-b-->\
814            </li>\
815            <!--fe-repeat$$end$$0$$fe-repeat-->\
816            <!--fe-repeat$$start$$1$$fe-repeat-->\
817            <li id=\"item-2\" data-name=\"Phone\" data-fe-c-0-2>\
818            <!--fe-b$$start$$2$$item.name$$fe-b-->Phone<!--fe-b$$end$$2$$item.name$$fe-b-->\
819            <!--fe-b$$start$$3$$specialTemplate$$fe-b-->\
820            <!--fe-b$$end$$3$$specialTemplate$$fe-b-->\
821            </li>\
822            <!--fe-repeat$$end$$1$$fe-repeat-->\
823            <!--fe-b$$end$$0$$itemTemplate$$fe-b-->\
824            </ul>\
825            <!--fe-b$$end$$2$$itemsTemplate$$fe-b-->\
826            </section>\
827            <!--fe-repeat$$end$$0$$fe-repeat-->\
828            <!--fe-repeat$$start$$1$$fe-repeat-->\
829            <section data-category=\"books\" data-fe-b-0>\
830            <!--fe-b$$start$$1$$category.title$$fe-b-->Books<!--fe-b$$end$$1$$category.title$$fe-b-->\
831            <!--fe-b$$start$$2$$itemsTemplate$$fe-b-->\
832            <!--fe-b$$end$$2$$itemsTemplate$$fe-b-->\
833            </section>\
834            <!--fe-repeat$$end$$1$$fe-repeat-->\
835            <!--fe-repeat$$start$$2$$fe-repeat-->\
836            <section data-category=\"toys\" data-fe-b-0>\
837            <!--fe-b$$start$$1$$category.title$$fe-b-->Toys<!--fe-b$$end$$1$$category.title$$fe-b-->\
838            <!--fe-b$$start$$2$$itemsTemplate$$fe-b-->\
839            <ul>\
840            <!--fe-b$$start$$0$$itemTemplate$$fe-b-->\
841            <!--fe-b$$end$$0$$itemTemplate$$fe-b-->\
842            </ul>\
843            <!--fe-b$$end$$2$$itemsTemplate$$fe-b-->\
844            </section>\
845            <!--fe-repeat$$end$$2$$fe-repeat-->\
846            <!--fe-b$$end$$1$$categoryTemplate$$fe-b-->\
847            </div>";
848
849        assert_eq!(output, expected);
850    }
851
852    #[test]
853    fn test_hydration_missing_signal_still_emits_markers() {
854        let mut fragments = HashMap::new();
855        fragments.insert(
856            "index.html".to_string(),
857            FragmentList {
858                fragments: vec![WebUIFragment::component("my-comp")],
859            },
860        );
861        fragments.insert(
862            "my-comp".to_string(),
863            FragmentList {
864                fragments: vec![
865                    WebUIFragment::raw("<p>"),
866                    WebUIFragment::signal("missing_field", false),
867                    WebUIFragment::raw("</p>"),
868                ],
869            },
870        );
871        let protocol = WebUIProtocol::new(fragments);
872        let state = test_json!({});
873        let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
874        // Hydration comments must be emitted even when signal is not found in state
875        assert!(
876            output.contains("<!--fe-b$$start$$0$$missing_field$$fe-b-->"),
877            "Expected binding start marker for missing signal, got: {output}"
878        );
879        assert!(
880            output.contains("<!--fe-b$$end$$0$$missing_field$$fe-b-->"),
881            "Expected binding end marker for missing signal, got: {output}"
882        );
883        // Start and end markers should be adjacent (no content between them)
884        assert!(output.contains(
885            "<!--fe-b$$start$$0$$missing_field$$fe-b--><!--fe-b$$end$$0$$missing_field$$fe-b-->"
886        ));
887    }
888
889    #[test]
890    fn test_hydration_missing_for_collection_still_emits_markers() {
891        let mut fragments = HashMap::new();
892        fragments.insert(
893            "index.html".to_string(),
894            FragmentList {
895                fragments: vec![WebUIFragment::component("my-comp")],
896            },
897        );
898        fragments.insert(
899            "my-comp".to_string(),
900            FragmentList {
901                fragments: vec![
902                    WebUIFragment::raw("<ul>"),
903                    WebUIFragment::for_loop("item", "missing_items", "loop-body"),
904                    WebUIFragment::raw("</ul>"),
905                ],
906            },
907        );
908        fragments.insert(
909            "loop-body".to_string(),
910            FragmentList {
911                fragments: vec![WebUIFragment::signal("item", false)],
912            },
913        );
914        let protocol = WebUIProtocol::new(fragments);
915        let state = test_json!({});
916        let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
917        // Hydration comments must be emitted even when collection is missing from state
918        assert!(
919            output.contains("<!--fe-b$$start$$0$$loop-body$$fe-b-->"),
920            "Expected binding start marker for missing collection, got: {output}"
921        );
922        assert!(
923            output.contains("<!--fe-b$$end$$0$$loop-body$$fe-b-->"),
924            "Expected binding end marker for missing collection, got: {output}"
925        );
926    }
927
928    #[test]
929    fn test_hydration_empty_string_signal_still_emits_markers() {
930        let mut fragments = HashMap::new();
931        fragments.insert(
932            "index.html".to_string(),
933            FragmentList {
934                fragments: vec![WebUIFragment::component("my-comp")],
935            },
936        );
937        fragments.insert(
938            "my-comp".to_string(),
939            FragmentList {
940                fragments: vec![
941                    WebUIFragment::raw("<p>"),
942                    WebUIFragment::signal("name", false),
943                    WebUIFragment::raw("</p>"),
944                ],
945            },
946        );
947        let protocol = WebUIProtocol::new(fragments);
948        let state = test_json!({"name": ""});
949        let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
950        assert!(
951            output.contains("<!--fe-b$$start$$0$$name$$fe-b-->"),
952            "Expected binding start marker for empty string signal, got: {output}"
953        );
954        assert!(
955            output.contains("<!--fe-b$$end$$0$$name$$fe-b-->"),
956            "Expected binding end marker for empty string signal, got: {output}"
957        );
958        assert!(output.contains("<!--fe-b$$start$$0$$name$$fe-b--><!--fe-b$$end$$0$$name$$fe-b-->"));
959    }
960
961    #[test]
962    fn test_hydration_empty_collection_still_emits_markers() {
963        let mut fragments = HashMap::new();
964        fragments.insert(
965            "index.html".to_string(),
966            FragmentList {
967                fragments: vec![WebUIFragment::component("my-comp")],
968            },
969        );
970        fragments.insert(
971            "my-comp".to_string(),
972            FragmentList {
973                fragments: vec![
974                    WebUIFragment::raw("<ul>"),
975                    WebUIFragment::for_loop("item", "items", "loop-body"),
976                    WebUIFragment::raw("</ul>"),
977                ],
978            },
979        );
980        fragments.insert(
981            "loop-body".to_string(),
982            FragmentList {
983                fragments: vec![WebUIFragment::signal("item", false)],
984            },
985        );
986        let protocol = WebUIProtocol::new(fragments);
987        let state = test_json!({"items": []});
988        let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
989        assert!(
990            output.contains("<!--fe-b$$start$$0$$loop-body$$fe-b-->"),
991            "Expected binding start marker for empty collection, got: {output}"
992        );
993        assert!(
994            output.contains("<!--fe-b$$end$$0$$loop-body$$fe-b-->"),
995            "Expected binding end marker for empty collection, got: {output}"
996        );
997    }
998}