1use super::HandlerPlugin;
18use crate::{HandlerError, ResponseWriter, Result};
19use serde_json::Value;
20use std::fmt::Write;
21use webui_protocol::FastElementData;
22
23const 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
31pub struct FastV3HydrationPlugin {
40 scopes: Vec<usize>,
43 buffer: String,
45}
46
47impl FastV3HydrationPlugin {
48 #[must_use]
51 pub fn new() -> Self {
52 Self {
53 scopes: vec![0],
55 buffer: String::with_capacity(64),
56 }
57 }
58
59 fn is_active(&self) -> bool {
61 self.scopes.len() > 1
62 }
63
64 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 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 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 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 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 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 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 plugin.push_scope();
286 plugin.on_binding_start("a", &mut writer).unwrap();
288 plugin.on_binding_end("a", &mut writer).unwrap();
289 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 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 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 plugin.push_scope();
365 plugin.on_binding_start("root", &mut writer).unwrap();
367 plugin.on_binding_end("root", &mut writer).unwrap();
368 plugin.push_scope();
370 writer.output.clear();
372 plugin.on_binding_start("for-1", &mut writer).unwrap();
373 assert_eq!(writer.output, "<!--fe:b-->");
374 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 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 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 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 assert!(!output.contains("<!--fe:r-->"));
545 assert!(!output.contains("<!--fe:/r-->"));
546 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 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 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 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 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 assert!(
688 output.contains("<!--fe:b-->"),
689 "Expected hydration markers in output"
690 );
691
692 assert!(
694 output.contains("title=\"Hello World\""),
695 "Expected clean attribute value without markers, got: {output}"
696 );
697
698 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 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 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 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 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 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 {
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 {
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}