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