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