Skip to main content

webui_handler/plugin/
fast_v3.rs

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