1use super::HandlerPlugin;
24use crate::{ResponseWriter, Result};
25use std::fmt::Write;
26
27const BINDING_START_PREFIX: &str = "<!--fe-b$$start$$";
29const BINDING_END_PREFIX: &str = "<!--fe-b$$end$$";
30const BINDING_SUFFIX: &str = "$$fe-b-->";
31const SEPARATOR: &str = "$$";
32const REPEAT_START_PREFIX: &str = "<!--fe-repeat$$start$$";
33const REPEAT_END_PREFIX: &str = "<!--fe-repeat$$end$$";
34const REPEAT_SUFFIX: &str = "$$fe-repeat-->";
35const ATTR_SINGLE_PREFIX: &str = " data-fe-b-";
36const ATTR_MULTI_PREFIX: &str = " data-fe-c-";
37
38pub struct FastHydrationPlugin {
47 scopes: Vec<usize>,
50 binding_stack: Vec<usize>,
52 buffer: String,
54}
55
56impl FastHydrationPlugin {
57 #[must_use]
60 pub fn new() -> Self {
61 Self {
62 scopes: vec![0],
64 binding_stack: Vec::with_capacity(8),
65 buffer: String::with_capacity(64),
66 }
67 }
68
69 fn is_active(&self) -> bool {
71 self.scopes.len() > 1
72 }
73
74 fn next_index(&mut self) -> usize {
76 if let Some(counter) = self.scopes.last_mut() {
77 let index = *counter;
78 *counter += 1;
79 index
80 } else {
81 0
82 }
83 }
84
85 fn next_index_n(&mut self, count: u32) -> usize {
87 if let Some(counter) = self.scopes.last_mut() {
88 let index = *counter;
89 *counter += count as usize;
90 index
91 } else {
92 0
93 }
94 }
95
96 fn build_binding_comment(&mut self, prefix: &str, index: usize, name: &str) {
98 self.buffer.clear();
99 self.buffer.push_str(prefix);
100 let _ = write!(self.buffer, "{}", index);
101 self.buffer.push_str(SEPARATOR);
102 self.buffer.push_str(name);
103 self.buffer.push_str(BINDING_SUFFIX);
104 }
105
106 fn build_repeat_comment(&mut self, prefix: &str, index: usize) {
108 self.buffer.clear();
109 self.buffer.push_str(prefix);
110 let _ = write!(self.buffer, "{}", index);
111 self.buffer.push_str(REPEAT_SUFFIX);
112 }
113
114 fn build_attribute_marker(&mut self, binding_index: usize, count: u32) {
116 self.buffer.clear();
117 if count == 1 {
118 self.buffer.push_str(ATTR_SINGLE_PREFIX);
119 let _ = write!(self.buffer, "{}", binding_index);
120 } else {
121 self.buffer.push_str(ATTR_MULTI_PREFIX);
122 let _ = write!(self.buffer, "{}-{}", binding_index, count);
123 }
124 }
125}
126
127impl Default for FastHydrationPlugin {
128 fn default() -> Self {
129 Self::new()
130 }
131}
132
133impl HandlerPlugin for FastHydrationPlugin {
134 fn push_scope(&mut self) {
135 self.scopes.push(0);
136 }
137
138 fn pop_scope(&mut self) {
139 self.scopes.pop();
140 }
141
142 fn on_binding_start(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
143 if !self.is_active() {
144 return Ok(());
145 }
146 let index = self.next_index();
147 self.binding_stack.push(index);
148 self.build_binding_comment(BINDING_START_PREFIX, index, name);
149 writer.write(&self.buffer)
150 }
151
152 fn on_binding_end(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
153 if !self.is_active() {
154 return Ok(());
155 }
156 let index = self.binding_stack.pop().unwrap_or(0);
157 self.build_binding_comment(BINDING_END_PREFIX, index, name);
158 writer.write(&self.buffer)
159 }
160
161 fn on_repeat_item_start(
162 &mut self,
163 index: usize,
164 writer: &mut dyn ResponseWriter,
165 ) -> Result<()> {
166 if !self.is_active() {
167 return Ok(());
168 }
169 self.build_repeat_comment(REPEAT_START_PREFIX, index);
170 writer.write(&self.buffer)
171 }
172
173 fn on_repeat_item_end(&mut self, index: usize, writer: &mut dyn ResponseWriter) -> Result<()> {
174 if !self.is_active() {
175 return Ok(());
176 }
177 self.build_repeat_comment(REPEAT_END_PREFIX, index);
178 writer.write(&self.buffer)
179 }
180
181 fn on_plugin_data(&mut self, data: &[u8], writer: &mut dyn ResponseWriter) -> Result<()> {
182 if !self.is_active() {
183 return Ok(());
184 }
185 if data.len() >= 4 {
187 let count = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
188 if count > 0 {
189 let binding_index = self.next_index_n(count);
190 self.build_attribute_marker(binding_index, count);
191 writer.write(&self.buffer)?;
192 }
193 }
194 Ok(())
195 }
196
197 fn on_render_complete(
198 &mut self,
199 protocol: &webui_protocol::WebUIProtocol,
200 rendered_components: &std::collections::HashSet<String>,
201 writer: &mut dyn ResponseWriter,
202 ) -> Result<()> {
203 for name in rendered_components {
207 if let Some(tmpl) = protocol
208 .components
209 .get(name)
210 .map(|c| c.template.as_str())
211 .filter(|s| !s.is_empty())
212 {
213 writer.write(tmpl)?;
214 }
215 }
216 Ok(())
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 struct TestWriter {
225 output: String,
226 }
227
228 impl TestWriter {
229 fn new() -> Self {
230 Self {
231 output: String::new(),
232 }
233 }
234 }
235
236 impl ResponseWriter for TestWriter {
237 fn write(&mut self, content: &str) -> Result<()> {
238 self.output.push_str(content);
239 Ok(())
240 }
241 fn end(&mut self) -> Result<()> {
242 Ok(())
243 }
244 }
245
246 #[test]
247 fn test_root_scope_disabled() {
248 let mut plugin = FastHydrationPlugin::new();
249 let mut writer = TestWriter::new();
250 plugin.on_binding_start("x", &mut writer).unwrap();
252 plugin.on_binding_end("x", &mut writer).unwrap();
253 plugin.on_repeat_item_start(0, &mut writer).unwrap();
254 plugin.on_repeat_item_end(0, &mut writer).unwrap();
255 let data = 3u32.to_le_bytes();
256 plugin.on_plugin_data(&data, &mut writer).unwrap();
257 assert_eq!(writer.output, "");
258 }
259
260 #[test]
261 fn test_binding_start_format() {
262 let mut plugin = FastHydrationPlugin::new();
263 plugin.push_scope();
264 let mut writer = TestWriter::new();
265 plugin.on_binding_start("userName", &mut writer).unwrap();
266 assert_eq!(writer.output, "<!--fe-b$$start$$0$$userName$$fe-b-->");
267 }
268
269 #[test]
270 fn test_binding_end_format() {
271 let mut plugin = FastHydrationPlugin::new();
272 plugin.push_scope();
273 let mut writer = TestWriter::new();
274 plugin.on_binding_start("userName", &mut writer).unwrap();
275 writer.output.clear();
276 plugin.on_binding_end("userName", &mut writer).unwrap();
277 assert_eq!(writer.output, "<!--fe-b$$end$$0$$userName$$fe-b-->");
278 }
279
280 #[test]
281 fn test_binding_index_increments() {
282 let mut plugin = FastHydrationPlugin::new();
283 plugin.push_scope();
284 let mut writer = TestWriter::new();
285 plugin.on_binding_start("a", &mut writer).unwrap();
286 plugin.on_binding_end("a", &mut writer).unwrap();
287 writer.output.clear();
288 plugin.on_binding_start("b", &mut writer).unwrap();
289 assert_eq!(writer.output, "<!--fe-b$$start$$1$$b$$fe-b-->");
290 }
291
292 #[test]
293 fn test_scope_resets_counter() {
294 let mut plugin = FastHydrationPlugin::new();
295 let mut writer = TestWriter::new();
296 plugin.push_scope();
298 plugin.on_binding_start("a", &mut writer).unwrap();
300 plugin.on_binding_end("a", &mut writer).unwrap();
301 plugin.push_scope();
303 writer.output.clear();
304 plugin.on_binding_start("b", &mut writer).unwrap();
305 assert_eq!(writer.output, "<!--fe-b$$start$$0$$b$$fe-b-->");
306 plugin.on_binding_end("b", &mut writer).unwrap();
307 plugin.pop_scope();
309 writer.output.clear();
310 plugin.on_binding_start("c", &mut writer).unwrap();
311 assert_eq!(writer.output, "<!--fe-b$$start$$1$$c$$fe-b-->");
312 }
313
314 #[test]
315 fn test_repeat_item_markers() {
316 let mut plugin = FastHydrationPlugin::new();
317 plugin.push_scope();
318 let mut writer = TestWriter::new();
319 plugin.on_repeat_item_start(0, &mut writer).unwrap();
320 assert_eq!(writer.output, "<!--fe-repeat$$start$$0$$fe-repeat-->");
321 writer.output.clear();
322 plugin.on_repeat_item_end(0, &mut writer).unwrap();
323 assert_eq!(writer.output, "<!--fe-repeat$$end$$0$$fe-repeat-->");
324 }
325
326 #[test]
327 fn test_attribute_binding_single() {
328 let mut plugin = FastHydrationPlugin::new();
329 plugin.push_scope();
330 let mut writer = TestWriter::new();
331 let data = 1u32.to_le_bytes();
332 plugin.on_plugin_data(&data, &mut writer).unwrap();
333 assert_eq!(writer.output, " data-fe-b-0");
334 }
335
336 #[test]
337 fn test_attribute_binding_multi() {
338 let mut plugin = FastHydrationPlugin::new();
339 plugin.push_scope();
340 let mut writer = TestWriter::new();
341 let data = 3u32.to_le_bytes();
342 plugin.on_plugin_data(&data, &mut writer).unwrap();
343 assert_eq!(writer.output, " data-fe-c-0-3");
344 }
345
346 #[test]
347 fn test_attribute_binding_zero_count_no_output() {
348 let mut plugin = FastHydrationPlugin::new();
349 plugin.push_scope();
350 let mut writer = TestWriter::new();
351 let data = 0u32.to_le_bytes();
352 plugin.on_plugin_data(&data, &mut writer).unwrap();
353 assert_eq!(writer.output, "");
354 }
355
356 #[test]
357 fn test_attribute_binding_advances_counter() {
358 let mut plugin = FastHydrationPlugin::new();
359 plugin.push_scope();
360 let mut writer = TestWriter::new();
361 let data = 3u32.to_le_bytes();
363 plugin.on_plugin_data(&data, &mut writer).unwrap();
364 writer.output.clear();
366 plugin.on_binding_start("x", &mut writer).unwrap();
367 assert_eq!(writer.output, "<!--fe-b$$start$$3$$x$$fe-b-->");
368 }
369
370 #[test]
371 fn test_nested_scopes_independent_counters() {
372 let mut plugin = FastHydrationPlugin::new();
373 let mut writer = TestWriter::new();
374 plugin.push_scope();
376 plugin.on_binding_start("root", &mut writer).unwrap();
378 plugin.on_binding_end("root", &mut writer).unwrap();
379 plugin.push_scope();
381 writer.output.clear();
383 plugin.on_binding_start("for-1", &mut writer).unwrap();
384 assert!(writer.output.contains("start$$0$$for-1"));
385 plugin.push_scope();
387 writer.output.clear();
388 plugin.on_binding_start("signal", &mut writer).unwrap();
389 assert!(writer.output.contains("start$$0$$signal"));
390 plugin.on_binding_end("signal", &mut writer).unwrap();
391 plugin.pop_scope();
392 plugin.on_binding_end("for-1", &mut writer).unwrap();
393 plugin.pop_scope();
394 writer.output.clear();
396 plugin.on_binding_start("root2", &mut writer).unwrap();
397 assert!(writer.output.contains("start$$1$$root2"));
398 }
399
400 #[test]
401 fn test_empty_plugin_data_ignored() {
402 let mut plugin = FastHydrationPlugin::new();
403 plugin.push_scope();
404 let mut writer = TestWriter::new();
405 plugin.on_plugin_data(&[], &mut writer).unwrap();
406 assert_eq!(writer.output, "");
407 }
408
409 #[test]
410 fn test_short_plugin_data_ignored() {
411 let mut plugin = FastHydrationPlugin::new();
412 plugin.push_scope();
413 let mut writer = TestWriter::new();
414 plugin.on_plugin_data(&[1, 2], &mut writer).unwrap();
415 assert_eq!(writer.output, "");
416 }
417
418 use std::collections::HashMap;
421 use webui_protocol::{
422 web_ui_fragment, ConditionExpr, FragmentList, LogicalOperator, WebUIFragment,
423 WebUIFragmentAttribute, WebUIProtocol,
424 };
425 use webui_test_utils::test_json;
426
427 use crate::{RenderOptions, WebUIHandler};
428
429 fn render_with_plugin(
430 protocol: &WebUIProtocol,
431 state: &serde_json::Value,
432 factory: fn() -> Box<dyn HandlerPlugin>,
433 ) -> String {
434 let mut writer = TestWriter::new();
435 let handler = WebUIHandler::with_plugin(factory);
436 handler
437 .handle(
438 protocol,
439 state,
440 &RenderOptions::new("index.html", "/"),
441 &mut writer,
442 )
443 .unwrap();
444 writer.output
445 }
446
447 fn render_no_plugin(protocol: &WebUIProtocol, state: &serde_json::Value) -> String {
448 let mut writer = TestWriter::new();
449 let handler = WebUIHandler::new();
450 handler
451 .handle(
452 protocol,
453 state,
454 &RenderOptions::new("index.html", "/"),
455 &mut writer,
456 )
457 .unwrap();
458 writer.output
459 }
460
461 #[test]
462 fn test_no_plugin_no_markers() {
463 let mut fragments = HashMap::new();
464 fragments.insert(
465 "index.html".to_string(),
466 FragmentList {
467 fragments: vec![
468 WebUIFragment::raw("<p>"),
469 WebUIFragment::signal("name", false),
470 WebUIFragment::raw("</p>"),
471 ],
472 },
473 );
474 let protocol = WebUIProtocol::new(fragments);
475 let state = test_json!({"name": "Alice"});
476 let output = render_no_plugin(&protocol, &state);
477 assert_eq!(output, "<p>Alice</p>");
478 assert!(!output.contains("fe-b"));
479 }
480
481 #[test]
482 fn test_hydration_signal_markers() {
483 let mut fragments = HashMap::new();
484 fragments.insert(
485 "index.html".to_string(),
486 FragmentList {
487 fragments: vec![
488 WebUIFragment::raw("<p>"),
489 WebUIFragment::signal("name", false),
490 WebUIFragment::raw("</p>"),
491 ],
492 },
493 );
494 let protocol = WebUIProtocol::new(fragments);
495 let state = test_json!({"name": "Alice"});
496 let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
497 assert_eq!(output, "<p>Alice</p>");
499 }
500
501 #[test]
502 fn test_hydration_for_loop_markers() {
503 let mut fragments = HashMap::new();
504 fragments.insert(
505 "index.html".to_string(),
506 FragmentList {
507 fragments: vec![WebUIFragment::for_loop("item", "items", "for-1")],
508 },
509 );
510 fragments.insert(
511 "for-1".to_string(),
512 FragmentList {
513 fragments: vec![WebUIFragment::signal("item", false)],
514 },
515 );
516 let protocol = WebUIProtocol::new(fragments);
517 let state = test_json!({"items": ["a", "b"]});
518 let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
519 assert!(!output.contains("$$for-1$$"));
521 assert!(!output.contains("fe-repeat"));
522 let item_pattern = "<!--fe-b$$start$$0$$item$$fe-b-->";
524 assert_eq!(output.matches(item_pattern).count(), 2);
525 }
526
527 #[test]
528 fn test_hydration_if_condition_markers() {
529 let mut fragments = HashMap::new();
530 fragments.insert(
531 "index.html".to_string(),
532 FragmentList {
533 fragments: vec![WebUIFragment::if_cond(
534 ConditionExpr::identifier("show"),
535 "if-1",
536 )],
537 },
538 );
539 fragments.insert(
540 "if-1".to_string(),
541 FragmentList {
542 fragments: vec![WebUIFragment::raw("<p>Visible</p>")],
543 },
544 );
545 let protocol = WebUIProtocol::new(fragments);
546
547 let state = test_json!({"show": true});
549 let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
550 assert!(output.contains("<p>Visible</p>"));
551 assert!(!output.contains("fe-b"));
552
553 let state = test_json!({"show": false});
555 let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
556 assert!(!output.contains("<p>Visible</p>"));
557 assert!(!output.contains("fe-b"));
558 }
559
560 #[test]
561 fn test_hydration_component_scope_reset() {
562 let mut fragments = HashMap::new();
563 fragments.insert(
564 "index.html".to_string(),
565 FragmentList {
566 fragments: vec![
567 WebUIFragment::signal("before", false),
568 WebUIFragment::component("my-comp"),
569 WebUIFragment::signal("after", false),
570 ],
571 },
572 );
573 fragments.insert(
574 "my-comp".to_string(),
575 FragmentList {
576 fragments: vec![WebUIFragment::signal("inner", false)],
577 },
578 );
579 let protocol = WebUIProtocol::new(fragments);
580 let state = test_json!({"before": "B", "inner": "I", "after": "A"});
581 let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
582 assert!(!output.contains("$$before$$"));
584 assert!(!output.contains("$$after$$"));
585 assert!(output.contains("<!--fe-b$$start$$0$$inner$$fe-b-->"));
587 assert!(output.contains("<!--fe-b$$end$$0$$inner$$fe-b-->"));
588 }
589
590 #[test]
591 fn test_hydration_plugin_data_fragment() {
592 let mut fragments = HashMap::new();
593 fragments.insert(
594 "index.html".to_string(),
595 FragmentList {
596 fragments: vec![
597 WebUIFragment::raw("<div"),
598 WebUIFragment::attribute("id", "itemId"),
599 WebUIFragment::attribute("title", "itemTitle"),
600 WebUIFragment::plugin(2u32.to_le_bytes().to_vec()),
601 WebUIFragment::raw(">content</div>"),
602 ],
603 },
604 );
605 let protocol = WebUIProtocol::new(fragments);
606 let state = test_json!({"itemId": "42", "itemTitle": "Hello"});
607 let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
608 assert!(!output.contains("data-fe-c"));
610 assert!(output.contains("id=\"42\""));
611 assert!(output.contains("title=\"Hello\""));
612 }
613
614 #[test]
615 fn test_hydration_no_markers_in_mixed_attribute_value() {
616 let mut fragments = HashMap::new();
618 fragments.insert(
619 "index.html".to_string(),
620 FragmentList {
621 fragments: vec![
622 WebUIFragment::raw("<my-component"),
623 WebUIFragment {
624 fragment: Some(web_ui_fragment::Fragment::Attribute(
625 WebUIFragmentAttribute {
626 name: "title".to_string(),
627 template: "attr-title".to_string(),
628 attr_start: true,
629 ..Default::default()
630 },
631 )),
632 },
633 WebUIFragment::raw(">"),
634 WebUIFragment::component("my-component"),
635 WebUIFragment::raw("</my-component>"),
636 ],
637 },
638 );
639 fragments.insert(
640 "attr-title".to_string(),
641 FragmentList {
642 fragments: vec![
643 WebUIFragment::raw("Hello "),
644 WebUIFragment::signal("name", false),
645 ],
646 },
647 );
648 fragments.insert(
649 "my-component".to_string(),
650 FragmentList {
651 fragments: vec![
652 WebUIFragment::raw("<span>"),
653 WebUIFragment::signal("content", false),
654 WebUIFragment::raw("</span>"),
655 ],
656 },
657 );
658 let protocol = WebUIProtocol::new(fragments);
659 let state = test_json!({"name": "World", "content": "CONTENT"});
660
661 let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
662
663 assert!(
665 output.contains("<!--fe-b"),
666 "Expected hydration markers in output"
667 );
668
669 assert!(
671 output.contains("title=\"Hello World\""),
672 "Expected clean attribute value without markers, got: {output}"
673 );
674
675 let title_start = output.find("title=\"").unwrap();
677 let title_end = output[title_start..].find('"').unwrap()
678 + output[title_start + 7..].find('"').unwrap()
679 + 7;
680 let title_value = &output[title_start..title_start + title_end + 1];
681 assert!(
682 !title_value.contains("fe-b"),
683 "Hydration markers leaked into attribute value: {title_value}"
684 );
685 }
686
687 #[test]
688 fn test_hydration_nested_for_if_streams_full() {
689 let mut fragments = HashMap::new();
691 fragments.insert(
692 "index.html".to_string(),
693 FragmentList {
694 fragments: vec![WebUIFragment::component("hydratableComponent")],
695 },
696 );
697 fragments.insert(
698 "hydratableComponent".to_string(),
699 FragmentList {
700 fragments: vec![
701 WebUIFragment::raw("<div>"),
702 WebUIFragment::signal("title", false),
703 WebUIFragment::for_loop("category", "categories", "categoryTemplate"),
704 WebUIFragment::raw("</div>"),
705 ],
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 },
729 );
730 fragments.insert(
731 "itemsTemplate".to_string(),
732 FragmentList {
733 fragments: vec![
734 WebUIFragment::raw("<ul>"),
735 WebUIFragment::for_loop("item", "category.items", "itemTemplate"),
736 WebUIFragment::raw("</ul>"),
737 ],
738 },
739 );
740 fragments.insert(
741 "itemTemplate".to_string(),
742 FragmentList {
743 fragments: vec![
744 WebUIFragment::raw("<li"),
745 WebUIFragment::attribute_template("id", "itemIdAttr"),
746 WebUIFragment::attribute("data-name", "item.name"),
747 WebUIFragment::plugin(2u32.to_le_bytes().to_vec()),
748 WebUIFragment::raw(">"),
749 WebUIFragment::signal("item.name", false),
750 WebUIFragment::if_cond(
751 ConditionExpr::identifier("item.special"),
752 "specialTemplate",
753 ),
754 WebUIFragment::raw("</li>"),
755 ],
756 },
757 );
758 fragments.insert(
759 "itemIdAttr".to_string(),
760 FragmentList {
761 fragments: vec![
762 WebUIFragment::raw("item-"),
763 WebUIFragment::signal("item.id", false),
764 ],
765 },
766 );
767 fragments.insert(
768 "specialTemplate".to_string(),
769 FragmentList {
770 fragments: vec![
771 WebUIFragment::raw(" ("),
772 WebUIFragment::signal("item.specialText", false),
773 WebUIFragment::raw(")"),
774 ],
775 },
776 );
777 let protocol = WebUIProtocol::new(fragments);
778 let state = test_json!({
779 "title": "My Store",
780 "categories": [
781 {
782 "name": "electronics",
783 "title": "Electronics",
784 "hasItems": true,
785 "alwaysTrue": true,
786 "items": [
787 {"id": "1", "name": "Laptop", "special": true, "specialText": "On Sale"},
788 {"id": "2", "name": "Phone", "special": false}
789 ]
790 },
791 {"name": "books", "title": "Books", "hasItems": false},
792 {"name": "toys", "title": "Toys", "hasItems": true, "alwaysTrue": true, "items": []}
793 ]
794 });
795
796 let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
797
798 let expected = "\
799 <div>\
800 <!--fe-b$$start$$0$$title$$fe-b-->My Store<!--fe-b$$end$$0$$title$$fe-b-->\
801 <!--fe-b$$start$$1$$categoryTemplate$$fe-b-->\
802 <!--fe-repeat$$start$$0$$fe-repeat-->\
803 <section data-category=\"electronics\" data-fe-b-0>\
804 <!--fe-b$$start$$1$$category.title$$fe-b-->Electronics<!--fe-b$$end$$1$$category.title$$fe-b-->\
805 <!--fe-b$$start$$2$$itemsTemplate$$fe-b-->\
806 <ul>\
807 <!--fe-b$$start$$0$$itemTemplate$$fe-b-->\
808 <!--fe-repeat$$start$$0$$fe-repeat-->\
809 <li id=\"item-1\" data-name=\"Laptop\" data-fe-c-0-2>\
810 <!--fe-b$$start$$2$$item.name$$fe-b-->Laptop<!--fe-b$$end$$2$$item.name$$fe-b-->\
811 <!--fe-b$$start$$3$$specialTemplate$$fe-b--> \
812 (<!--fe-b$$start$$0$$item.specialText$$fe-b-->On Sale<!--fe-b$$end$$0$$item.specialText$$fe-b-->)\
813 <!--fe-b$$end$$3$$specialTemplate$$fe-b-->\
814 </li>\
815 <!--fe-repeat$$end$$0$$fe-repeat-->\
816 <!--fe-repeat$$start$$1$$fe-repeat-->\
817 <li id=\"item-2\" data-name=\"Phone\" data-fe-c-0-2>\
818 <!--fe-b$$start$$2$$item.name$$fe-b-->Phone<!--fe-b$$end$$2$$item.name$$fe-b-->\
819 <!--fe-b$$start$$3$$specialTemplate$$fe-b-->\
820 <!--fe-b$$end$$3$$specialTemplate$$fe-b-->\
821 </li>\
822 <!--fe-repeat$$end$$1$$fe-repeat-->\
823 <!--fe-b$$end$$0$$itemTemplate$$fe-b-->\
824 </ul>\
825 <!--fe-b$$end$$2$$itemsTemplate$$fe-b-->\
826 </section>\
827 <!--fe-repeat$$end$$0$$fe-repeat-->\
828 <!--fe-repeat$$start$$1$$fe-repeat-->\
829 <section data-category=\"books\" data-fe-b-0>\
830 <!--fe-b$$start$$1$$category.title$$fe-b-->Books<!--fe-b$$end$$1$$category.title$$fe-b-->\
831 <!--fe-b$$start$$2$$itemsTemplate$$fe-b-->\
832 <!--fe-b$$end$$2$$itemsTemplate$$fe-b-->\
833 </section>\
834 <!--fe-repeat$$end$$1$$fe-repeat-->\
835 <!--fe-repeat$$start$$2$$fe-repeat-->\
836 <section data-category=\"toys\" data-fe-b-0>\
837 <!--fe-b$$start$$1$$category.title$$fe-b-->Toys<!--fe-b$$end$$1$$category.title$$fe-b-->\
838 <!--fe-b$$start$$2$$itemsTemplate$$fe-b-->\
839 <ul>\
840 <!--fe-b$$start$$0$$itemTemplate$$fe-b-->\
841 <!--fe-b$$end$$0$$itemTemplate$$fe-b-->\
842 </ul>\
843 <!--fe-b$$end$$2$$itemsTemplate$$fe-b-->\
844 </section>\
845 <!--fe-repeat$$end$$2$$fe-repeat-->\
846 <!--fe-b$$end$$1$$categoryTemplate$$fe-b-->\
847 </div>";
848
849 assert_eq!(output, expected);
850 }
851
852 #[test]
853 fn test_hydration_missing_signal_still_emits_markers() {
854 let mut fragments = HashMap::new();
855 fragments.insert(
856 "index.html".to_string(),
857 FragmentList {
858 fragments: vec![WebUIFragment::component("my-comp")],
859 },
860 );
861 fragments.insert(
862 "my-comp".to_string(),
863 FragmentList {
864 fragments: vec![
865 WebUIFragment::raw("<p>"),
866 WebUIFragment::signal("missing_field", false),
867 WebUIFragment::raw("</p>"),
868 ],
869 },
870 );
871 let protocol = WebUIProtocol::new(fragments);
872 let state = test_json!({});
873 let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
874 assert!(
876 output.contains("<!--fe-b$$start$$0$$missing_field$$fe-b-->"),
877 "Expected binding start marker for missing signal, got: {output}"
878 );
879 assert!(
880 output.contains("<!--fe-b$$end$$0$$missing_field$$fe-b-->"),
881 "Expected binding end marker for missing signal, got: {output}"
882 );
883 assert!(output.contains(
885 "<!--fe-b$$start$$0$$missing_field$$fe-b--><!--fe-b$$end$$0$$missing_field$$fe-b-->"
886 ));
887 }
888
889 #[test]
890 fn test_hydration_missing_for_collection_still_emits_markers() {
891 let mut fragments = HashMap::new();
892 fragments.insert(
893 "index.html".to_string(),
894 FragmentList {
895 fragments: vec![WebUIFragment::component("my-comp")],
896 },
897 );
898 fragments.insert(
899 "my-comp".to_string(),
900 FragmentList {
901 fragments: vec![
902 WebUIFragment::raw("<ul>"),
903 WebUIFragment::for_loop("item", "missing_items", "loop-body"),
904 WebUIFragment::raw("</ul>"),
905 ],
906 },
907 );
908 fragments.insert(
909 "loop-body".to_string(),
910 FragmentList {
911 fragments: vec![WebUIFragment::signal("item", false)],
912 },
913 );
914 let protocol = WebUIProtocol::new(fragments);
915 let state = test_json!({});
916 let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
917 assert!(
919 output.contains("<!--fe-b$$start$$0$$loop-body$$fe-b-->"),
920 "Expected binding start marker for missing collection, got: {output}"
921 );
922 assert!(
923 output.contains("<!--fe-b$$end$$0$$loop-body$$fe-b-->"),
924 "Expected binding end marker for missing collection, got: {output}"
925 );
926 }
927
928 #[test]
929 fn test_hydration_empty_string_signal_still_emits_markers() {
930 let mut fragments = HashMap::new();
931 fragments.insert(
932 "index.html".to_string(),
933 FragmentList {
934 fragments: vec![WebUIFragment::component("my-comp")],
935 },
936 );
937 fragments.insert(
938 "my-comp".to_string(),
939 FragmentList {
940 fragments: vec![
941 WebUIFragment::raw("<p>"),
942 WebUIFragment::signal("name", false),
943 WebUIFragment::raw("</p>"),
944 ],
945 },
946 );
947 let protocol = WebUIProtocol::new(fragments);
948 let state = test_json!({"name": ""});
949 let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
950 assert!(
951 output.contains("<!--fe-b$$start$$0$$name$$fe-b-->"),
952 "Expected binding start marker for empty string signal, got: {output}"
953 );
954 assert!(
955 output.contains("<!--fe-b$$end$$0$$name$$fe-b-->"),
956 "Expected binding end marker for empty string signal, got: {output}"
957 );
958 assert!(output.contains("<!--fe-b$$start$$0$$name$$fe-b--><!--fe-b$$end$$0$$name$$fe-b-->"));
959 }
960
961 #[test]
962 fn test_hydration_empty_collection_still_emits_markers() {
963 let mut fragments = HashMap::new();
964 fragments.insert(
965 "index.html".to_string(),
966 FragmentList {
967 fragments: vec![WebUIFragment::component("my-comp")],
968 },
969 );
970 fragments.insert(
971 "my-comp".to_string(),
972 FragmentList {
973 fragments: vec![
974 WebUIFragment::raw("<ul>"),
975 WebUIFragment::for_loop("item", "items", "loop-body"),
976 WebUIFragment::raw("</ul>"),
977 ],
978 },
979 );
980 fragments.insert(
981 "loop-body".to_string(),
982 FragmentList {
983 fragments: vec![WebUIFragment::signal("item", false)],
984 },
985 );
986 let protocol = WebUIProtocol::new(fragments);
987 let state = test_json!({"items": []});
988 let output = render_with_plugin(&protocol, &state, || Box::new(FastHydrationPlugin::new()));
989 assert!(
990 output.contains("<!--fe-b$$start$$0$$loop-body$$fe-b-->"),
991 "Expected binding start marker for empty collection, got: {output}"
992 );
993 assert!(
994 output.contains("<!--fe-b$$end$$0$$loop-body$$fe-b-->"),
995 "Expected binding end marker for empty collection, got: {output}"
996 );
997 }
998}