Skip to main content

webui_handler/plugin/
fast_v2.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Deprecated FAST 2 hydration plugin for the WebUI handler.
5//!
6//! Emits the legacy FAST 2 marker format used by the `fast` and `fast-v2`
7//! plugin names. FAST 3 hydration is implemented separately in `fast_v3`.
8//!
9//! ## FAST 2 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//! - **Single attribute binding**: ` data-fe-b-INDEX`
16//! - **Multiple attribute bindings**: ` data-fe-c-INDEX-COUNT`
17use super::HandlerPlugin;
18use crate::{HandlerError, ResponseWriter, Result};
19use serde_json::Value;
20use std::fmt::Write;
21use webui_protocol::FastElementData;
22
23// FAST 2 comment format constants
24const V2_BINDING_START_PREFIX: &str = "<!--fe-b$$start$$";
25const V2_BINDING_END_PREFIX: &str = "<!--fe-b$$end$$";
26const V2_BINDING_SUFFIX: &str = "$$fe-b-->";
27const V2_SEPARATOR: &str = "$$";
28const V2_REPEAT_START_PREFIX: &str = "<!--fe-repeat$$start$$";
29const V2_REPEAT_END_PREFIX: &str = "<!--fe-repeat$$end$$";
30const V2_REPEAT_SUFFIX: &str = "$$fe-repeat-->";
31const V2_ATTR_SINGLE_PREFIX: &str = " data-fe-b-";
32const V2_ATTR_MULTI_PREFIX: &str = " data-fe-c-";
33
34/// Deprecated FAST 2 hydration handler plugin.
35///
36/// Emits the legacy FAST 2 marker format used by the `fast` and `fast-v2`
37/// plugin names. New FAST 3 applications should use the separate
38/// `fast_v3::FastV3HydrationPlugin` implementation through `fast-v3` instead.
39pub struct FastV2HydrationPlugin {
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    /// Stack of binding indices for matching start/end pairs.
44    binding_stack: Vec<usize>,
45    /// Reusable buffer for formatting markers without allocation.
46    buffer: String,
47}
48
49impl FastV2HydrationPlugin {
50    /// Create a new deprecated FAST 2 hydration plugin.
51    /// The initial root scope is disabled — markers only emit in child scopes.
52    #[must_use]
53    pub fn new() -> Self {
54        Self {
55            // Root scope (index 0) is disabled — only scopes.len() > 1 are active.
56            scopes: vec![0],
57            binding_stack: Vec::with_capacity(8),
58            buffer: String::with_capacity(64),
59        }
60    }
61
62    /// Whether the current scope is active (not the root scope).
63    fn is_active(&self) -> bool {
64        self.scopes.len() > 1
65    }
66
67    /// Get the next binding index in the current scope, advancing the counter.
68    fn next_index(&mut self) -> usize {
69        if let Some(counter) = self.scopes.last_mut() {
70            let index = *counter;
71            *counter += 1;
72            index
73        } else {
74            0
75        }
76    }
77
78    /// Get the next binding index, advancing the counter by `count`.
79    fn next_index_n(&mut self, count: u32) -> usize {
80        if let Some(counter) = self.scopes.last_mut() {
81            let index = *counter;
82            *counter += count as usize;
83            index
84        } else {
85            0
86        }
87    }
88
89    /// Build a binding comment into the reusable buffer.
90    fn build_binding_comment(&mut self, prefix: &str, index: usize, name: &str) {
91        self.buffer.clear();
92        self.buffer.push_str(prefix);
93        let _ = write!(self.buffer, "{}", index);
94        self.buffer.push_str(V2_SEPARATOR);
95        self.buffer.push_str(name);
96        self.buffer.push_str(V2_BINDING_SUFFIX);
97    }
98
99    /// Build a repeat comment into the reusable buffer.
100    fn build_repeat_comment(&mut self, prefix: &str, index: usize) {
101        self.buffer.clear();
102        self.buffer.push_str(prefix);
103        let _ = write!(self.buffer, "{}", index);
104        self.buffer.push_str(V2_REPEAT_SUFFIX);
105    }
106
107    /// Build an attribute binding marker into the reusable buffer.
108    fn build_attribute_marker(&mut self, binding_index: usize, count: u32) {
109        self.buffer.clear();
110        if count == 1 {
111            self.buffer.push_str(V2_ATTR_SINGLE_PREFIX);
112            let _ = write!(self.buffer, "{}", binding_index);
113        } else {
114            self.buffer.push_str(V2_ATTR_MULTI_PREFIX);
115            let _ = write!(self.buffer, "{}-{}", binding_index, count);
116        }
117    }
118}
119
120impl Default for FastV2HydrationPlugin {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126impl HandlerPlugin for FastV2HydrationPlugin {
127    fn push_scope(&mut self) {
128        self.scopes.push(0);
129    }
130
131    fn pop_scope(&mut self) {
132        self.scopes.pop();
133    }
134
135    fn on_binding_start(
136        &mut self,
137        name: &str,
138        _raw: bool,
139        writer: &mut dyn ResponseWriter,
140    ) -> Result<()> {
141        if !self.is_active() {
142            return Ok(());
143        }
144        let index = self.next_index();
145        self.binding_stack.push(index);
146        self.build_binding_comment(V2_BINDING_START_PREFIX, index, name);
147        writer.write(&self.buffer)
148    }
149
150    fn on_binding_end(
151        &mut self,
152        name: &str,
153        _raw: bool,
154        writer: &mut dyn ResponseWriter,
155    ) -> Result<()> {
156        if !self.is_active() {
157            return Ok(());
158        }
159        let index = self.binding_stack.pop().unwrap_or(0);
160        self.build_binding_comment(V2_BINDING_END_PREFIX, index, name);
161        writer.write(&self.buffer)
162    }
163
164    fn on_repeat_item_start(
165        &mut self,
166        index: usize,
167        writer: &mut dyn ResponseWriter,
168    ) -> Result<()> {
169        if !self.is_active() {
170            return Ok(());
171        }
172        self.build_repeat_comment(V2_REPEAT_START_PREFIX, index);
173        writer.write(&self.buffer)
174    }
175
176    fn on_repeat_item_end(&mut self, index: usize, writer: &mut dyn ResponseWriter) -> Result<()> {
177        if !self.is_active() {
178            return Ok(());
179        }
180        self.build_repeat_comment(V2_REPEAT_END_PREFIX, index);
181        writer.write(&self.buffer)
182    }
183
184    fn on_element_data(&mut self, data: &[u8], writer: &mut dyn ResponseWriter) -> Result<()> {
185        if !self.is_active() {
186            return Ok(());
187        }
188        let (decoded, reset_child_index) = FastElementData::decode_v2(data).map_err(|error| {
189            HandlerError::PluginData(format!(
190                "FAST v2 hydration plugin received invalid element data: {error}"
191            ))
192        })?;
193        if decoded.binding_count > 0 {
194            let binding_index = self.next_index_n(decoded.binding_count);
195            self.build_attribute_marker(binding_index, decoded.binding_count);
196            writer.write(&self.buffer)?;
197        }
198        if reset_child_index {
199            if let Some(counter) = self.scopes.last_mut() {
200                *counter = 0;
201            }
202        }
203        Ok(())
204    }
205
206    /// FAST emits scalar attributes on route component elements.
207    /// Components read these via `@attr` and their connection lifecycle.
208    fn write_route_component_state(
209        &self,
210        state: &Value,
211        writer: &mut dyn ResponseWriter,
212    ) -> Result<()> {
213        super::fast::write_route_component_state(state, writer)
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    #![allow(clippy::disallowed_methods)]
220
221    use super::*;
222
223    struct TestWriter {
224        output: String,
225    }
226
227    impl TestWriter {
228        fn new() -> Self {
229            Self {
230                output: String::new(),
231            }
232        }
233    }
234
235    impl ResponseWriter for TestWriter {
236        fn write(&mut self, content: &str) -> Result<()> {
237            self.output.push_str(content);
238            Ok(())
239        }
240        fn end(&mut self) -> Result<()> {
241            Ok(())
242        }
243    }
244
245    #[test]
246    fn test_fast_v2_binding_marker_format() {
247        let mut plugin = FastV2HydrationPlugin::new();
248        plugin.push_scope();
249        let mut writer = TestWriter::new();
250        plugin
251            .on_binding_start("userName", false, &mut writer)
252            .unwrap();
253        plugin
254            .on_binding_end("userName", false, &mut writer)
255            .unwrap();
256        assert_eq!(
257            writer.output,
258            "<!--fe-b$$start$$0$$userName$$fe-b--><!--fe-b$$end$$0$$userName$$fe-b-->"
259        );
260    }
261
262    #[test]
263    fn test_fast_v2_binding_sequence_uses_indexes() {
264        let mut plugin = FastV2HydrationPlugin::new();
265        plugin.push_scope();
266        let mut writer = TestWriter::new();
267        plugin.on_binding_start("a", false, &mut writer).unwrap();
268        plugin.on_binding_end("a", false, &mut writer).unwrap();
269        writer.output.clear();
270        plugin.on_binding_start("b", false, &mut writer).unwrap();
271        assert_eq!(writer.output, "<!--fe-b$$start$$1$$b$$fe-b-->");
272    }
273
274    #[test]
275    fn test_fast_v2_repeat_marker_format() {
276        let mut plugin = FastV2HydrationPlugin::new();
277        plugin.push_scope();
278        let mut writer = TestWriter::new();
279        plugin.on_repeat_item_start(2, &mut writer).unwrap();
280        plugin.on_repeat_item_end(2, &mut writer).unwrap();
281        assert_eq!(
282            writer.output,
283            "<!--fe-repeat$$start$$2$$fe-repeat--><!--fe-repeat$$end$$2$$fe-repeat-->"
284        );
285    }
286
287    #[test]
288    fn test_fast_v2_attribute_marker_formats() {
289        let mut single = FastV2HydrationPlugin::new();
290        single.push_scope();
291        let mut writer = TestWriter::new();
292        let one = FastElementData { binding_count: 1 }.encode_v2(false);
293        single.on_element_data(&one, &mut writer).unwrap();
294        assert_eq!(writer.output, " data-fe-b-0");
295
296        let mut multi = FastV2HydrationPlugin::new();
297        multi.push_scope();
298        writer.output.clear();
299        let three = FastElementData { binding_count: 3 }.encode_v2(false);
300        multi.on_element_data(&three, &mut writer).unwrap();
301        assert_eq!(writer.output, " data-fe-c-0-3");
302    }
303
304    #[test]
305    fn test_fast_v2_attribute_count_advances_binding_index() {
306        let mut plugin = FastV2HydrationPlugin::new();
307        plugin.push_scope();
308        let mut writer = TestWriter::new();
309        let three = FastElementData { binding_count: 3 }.encode_v2(false);
310        plugin.on_element_data(&three, &mut writer).unwrap();
311
312        writer.output.clear();
313        plugin.on_binding_start("next", false, &mut writer).unwrap();
314        assert_eq!(writer.output, "<!--fe-b$$start$$3$$next$$fe-b-->");
315    }
316
317    #[test]
318    fn test_fast_v2_root_host_bindings_reset_child_index() {
319        let mut plugin = FastV2HydrationPlugin::new();
320        plugin.push_scope();
321        let mut writer = TestWriter::new();
322        let root = FastElementData { binding_count: 2 }.encode_v2(true);
323        plugin.on_element_data(&root, &mut writer).unwrap();
324        let child = FastElementData { binding_count: 1 }.encode_v2(false);
325        plugin.on_element_data(&child, &mut writer).unwrap();
326        assert_eq!(writer.output, " data-fe-c-0-2 data-fe-b-0");
327    }
328
329    #[test]
330    fn test_fast_v2_rejects_four_byte_element_data() {
331        let mut plugin = FastV2HydrationPlugin::new();
332        plugin.push_scope();
333        let mut writer = TestWriter::new();
334        let result = plugin.on_element_data(&1u32.to_le_bytes(), &mut writer);
335        assert!(
336            matches!(result, Err(crate::HandlerError::PluginData(ref msg)) if msg.contains("5 bytes")),
337            "four-byte FAST 2 data should be rejected: {result:?}"
338        );
339        assert_eq!(writer.output, "");
340    }
341
342    #[test]
343    fn test_fast_v2_parser_and_handler_keep_child_index_local() {
344        use crate::{RenderOptions, WebUIHandler};
345        use webui_parser::{
346            plugin::fast_v2::FastV2ParserPlugin, ComponentRegistration, HtmlParser,
347        };
348        use webui_protocol::WebUIProtocol;
349
350        let mut parser = HtmlParser::with_plugin(Box::new(FastV2ParserPlugin::new()));
351        parser
352            .component_registry_mut()
353            .register_component(ComponentRegistration::new(
354                "binding-card",
355                r#"<f-template name="binding-card" shadowrootmode="open"><template @click="{click($e)}" @keydown="{keydown($e)}"><slot f-ref="{slot}"></slot></template></f-template>"#,
356                None,
357                true,
358            ))
359            .unwrap();
360        parser
361            .parse("index.html", "<binding-card></binding-card>")
362            .unwrap();
363        let mut protocol = WebUIProtocol::new(parser.into_fragment_records());
364        protocol
365            .components
366            .entry("binding-card".to_string())
367            .or_default()
368            .uses_shadow_dom = true;
369        protocol.populate_style_closures(&["index.html"]);
370        let handler = WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()));
371        let mut writer = TestWriter::new();
372        handler
373            .handle(
374                &protocol,
375                &serde_json::json!({}),
376                &RenderOptions::new("index.html", "/"),
377                &mut writer,
378            )
379            .unwrap();
380
381        assert!(
382            writer.output.contains(concat!(
383                r#"<template shadowrootmode="open" data-fe-c-0-2>"#,
384                r#"<slot data-fe-b-0></slot></template>"#
385            )),
386            "FAST 2 child markers should exclude consumed host bindings: {}",
387            writer.output
388        );
389    }
390
391    #[test]
392    fn test_fast_v2_root_scope_disabled() {
393        let mut plugin = FastV2HydrationPlugin::new();
394        let mut writer = TestWriter::new();
395        plugin.on_binding_start("x", false, &mut writer).unwrap();
396        plugin.on_binding_end("x", false, &mut writer).unwrap();
397        plugin.on_repeat_item_start(0, &mut writer).unwrap();
398        plugin.on_repeat_item_end(0, &mut writer).unwrap();
399        let data = FastElementData { binding_count: 3 }.encode_v2(false);
400        plugin.on_element_data(&data, &mut writer).unwrap();
401        assert_eq!(writer.output, "");
402    }
403}