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(
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 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 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 plugin.push_scope();
267 plugin.on_binding_start("a", false, &mut writer).unwrap();
269 plugin.on_binding_end("a", false, &mut writer).unwrap();
270 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 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 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 plugin.push_scope();
346 plugin.on_binding_start("root", false, &mut writer).unwrap();
348 plugin.on_binding_end("root", false, &mut writer).unwrap();
349 plugin.push_scope();
351 writer.output.clear();
353 plugin
354 .on_binding_start("for-1", false, &mut writer)
355 .unwrap();
356 assert_eq!(writer.output, "<!--fe:b-->");
357 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 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 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 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 assert!(!output.contains("<!--fe:r-->"));
511 assert!(!output.contains("<!--fe:/r-->"));
512 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 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 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 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 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 assert!(
663 output.contains("<!--fe:b-->"),
664 "Expected hydration markers in output"
665 );
666
667 assert!(
669 output.contains("title=\"Hello World\""),
670 "Expected clean attribute value without markers, got: {output}"
671 );
672
673 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 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 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 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 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 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 {
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 {
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}