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