1use crate::{route_matcher, route_renderer, HandlerError};
12use route_matcher::CompiledRouteCache;
13use serde_json::Value;
14use std::collections::{HashMap, HashSet};
15use webui_protocol::{web_ui_fragment::Fragment, WebUIFragmentRoute, WebUIProtocol};
16
17pub struct ProtocolIndex {
25 pub component_index: HashMap<String, u32>,
27 pub route_cache: CompiledRouteCache,
29 template_metadata_cache: HashMap<String, Value>,
31}
32
33struct ComponentAssets {
34 styles: Vec<Value>,
35 templates: serde_json::Map<String, Value>,
36 functions: serde_json::Map<String, Value>,
37}
38
39impl ProtocolIndex {
40 #[must_use]
42 pub fn new(protocol: &WebUIProtocol) -> Self {
43 Self {
44 component_index: build_component_index(protocol),
45 route_cache: CompiledRouteCache::new(),
46 template_metadata_cache: HashMap::new(),
47 }
48 }
49}
50
51pub fn build_component_index(protocol: &WebUIProtocol) -> HashMap<String, u32> {
60 let mut sorted: Vec<&String> = protocol
61 .fragments
62 .keys()
63 .filter(|key| key.contains('-'))
64 .collect();
65 sorted.sort_unstable();
66 let mut index = HashMap::with_capacity(sorted.len());
67 for (i, name) in sorted.into_iter().enumerate() {
68 #[allow(clippy::cast_possible_truncation)]
70 let idx = i as u32;
71 index.insert(name.clone(), idx);
72 }
73 index
74}
75
76fn has_component(inventory: &[u8], index: u32) -> bool {
78 let byte_idx = (index / 8) as usize;
79 let bit_idx = index % 8;
80 byte_idx < inventory.len() && inventory[byte_idx] & (1 << bit_idx) != 0
81}
82
83fn set_component(inventory: &mut Vec<u8>, index: u32) {
85 let byte_idx = (index / 8) as usize;
86 let bit_idx = index % 8;
87 if byte_idx >= inventory.len() {
88 inventory.resize(byte_idx + 1, 0);
89 }
90 inventory[byte_idx] |= 1 << bit_idx;
91}
92
93pub fn parse_inventory(hex: &str) -> Result<Vec<u8>, HandlerError> {
97 if hex.is_empty() {
98 return Ok(Vec::new());
99 }
100 if !hex.len().is_multiple_of(2) {
101 return Err(HandlerError::Protocol(
102 webui_protocol::ProtocolError::Validation(format!(
103 "inventory hex has odd length: {}",
104 hex.len()
105 )),
106 ));
107 }
108 let mut bytes = Vec::with_capacity(hex.len() / 2);
109 let mut chars = hex.bytes();
110 while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
111 let h = route_matcher::hex_val(hi).ok_or_else(|| {
112 HandlerError::Protocol(webui_protocol::ProtocolError::Validation(format!(
113 "invalid hex digit in inventory: {:#04x}",
114 hi
115 )))
116 })?;
117 let l = route_matcher::hex_val(lo).ok_or_else(|| {
118 HandlerError::Protocol(webui_protocol::ProtocolError::Validation(format!(
119 "invalid hex digit in inventory: {:#04x}",
120 lo
121 )))
122 })?;
123 bytes.push((h << 4) | l);
124 }
125 Ok(bytes)
126}
127
128pub fn encode_inventory(inv: &[u8]) -> String {
130 const HEX: &[u8; 16] = b"0123456789abcdef";
131 let mut encoded = String::with_capacity(inv.len() * 2);
132 for byte in inv {
133 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
134 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
135 }
136 encoded
137}
138
139pub(crate) fn encode_component_inventory(
141 component_names: &HashSet<String>,
142 index: &HashMap<String, u32>,
143) -> String {
144 let mut inventory = Vec::new();
145 for name in component_names {
146 if let Some(&idx) = index.get(name.as_str()) {
147 set_component(&mut inventory, idx);
148 }
149 }
150 encode_inventory(&inventory)
151}
152
153pub fn get_needed_components(
158 protocol: &WebUIProtocol,
159 entry_id: &str,
160 inventory_hex: &str,
161 component_index: &HashMap<String, u32>,
162) -> Result<(Vec<String>, String), HandlerError> {
163 let component_names = collect_inventoryable_components(
164 protocol,
165 entry_id,
166 None,
167 true,
168 &mut CompiledRouteCache::new(),
169 );
170 filter_needed_components(&component_names, inventory_hex, component_index)
171}
172
173pub fn get_needed_components_for_request(
178 protocol: &WebUIProtocol,
179 entry_id: &str,
180 request_path: &str,
181 inventory_hex: &str,
182 component_index: &HashMap<String, u32>,
183) -> Result<(Vec<String>, String), HandlerError> {
184 let component_names = collect_inventoryable_components(
185 protocol,
186 entry_id,
187 Some(request_path),
188 false,
189 &mut CompiledRouteCache::new(),
190 );
191 filter_needed_components(&component_names, inventory_hex, component_index)
192}
193
194pub(crate) fn collect_reachable_components_for_request(
196 protocol: &WebUIProtocol,
197 entry_id: &str,
198 request_path: &str,
199 cache: &mut CompiledRouteCache,
200) -> HashSet<String> {
201 collect_inventoryable_components(protocol, entry_id, Some(request_path), false, cache)
204 .into_iter()
205 .collect()
206}
207
208pub fn filter_needed_components(
218 component_names: &[String],
219 inventory_hex: &str,
220 index: &HashMap<String, u32>,
221) -> Result<(Vec<String>, String), HandlerError> {
222 let client_inv = parse_inventory(inventory_hex)?;
223 let mut updated_inv = client_inv.clone();
224
225 let mut needed = Vec::with_capacity(component_names.len());
226 for name in component_names {
227 if let Some(&idx) = index.get(name.as_str()) {
228 if !has_component(&client_inv, idx) {
229 needed.push(name.clone());
230 }
231 set_component(&mut updated_inv, idx);
232 } else {
233 needed.push(name.clone());
237 }
238 }
239
240 Ok((needed, encode_inventory(&updated_inv)))
241}
242
243fn has_template_payload(component: &webui_protocol::ComponentData) -> bool {
244 !component.template_json.is_empty() || !component.template.is_empty()
245}
246
247#[derive(Debug)]
248struct QueuedFragment {
249 id: String,
250 inventoryable: bool,
251 route_base: String,
253}
254
255struct ChildWalkCtx<'a> {
258 request_path: &'a str,
259 protocol: &'a WebUIProtocol,
260 cache: &'a mut CompiledRouteCache,
261}
262
263fn collect_inventoryable_components(
274 protocol: &WebUIProtocol,
275 entry_id: &str,
276 request_path: Option<&str>,
277 root_inventoryable: bool,
278 cache: &mut CompiledRouteCache,
279) -> Vec<String> {
280 let mut visited_fragments = HashSet::new();
281 let mut seen_components = HashSet::new();
286 let mut component_ids: Vec<String> = Vec::new();
287 let mut stack = vec![QueuedFragment {
288 id: entry_id.to_string(),
289 inventoryable: root_inventoryable,
290 route_base: "/".to_string(),
291 }];
292
293 while let Some(queued) = stack.pop() {
294 if queued.id.is_empty() {
295 continue;
296 }
297
298 if queued.inventoryable && seen_components.insert(queued.id.clone()) {
299 component_ids.push(queued.id.clone());
300 }
301
302 if !visited_fragments.insert(queued.id.clone()) {
303 continue;
304 }
305
306 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
307 continue;
308 };
309
310 let matched_route = request_path.and_then(|path| {
311 route_renderer::find_best_route_match(
312 &frag_list.fragments,
313 path,
314 &queued.route_base,
315 cache,
316 )
317 });
318
319 for frag in frag_list.fragments.iter().rev() {
322 match frag.fragment.as_ref() {
323 Some(Fragment::Component(component)) => {
324 stack.push(QueuedFragment {
325 id: component.fragment_id.clone(),
326 inventoryable: true,
327 route_base: queued.route_base.clone(),
328 });
329 }
330 Some(Fragment::ForLoop(for_loop)) => {
331 stack.push(QueuedFragment {
332 id: for_loop.fragment_id.clone(),
333 inventoryable: false,
334 route_base: queued.route_base.clone(),
335 });
336 }
337 Some(Fragment::IfCond(if_cond)) => {
338 stack.push(QueuedFragment {
339 id: if_cond.fragment_id.clone(),
340 inventoryable: false,
341 route_base: queued.route_base.clone(),
342 });
343 }
344 Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
345 stack.push(QueuedFragment {
346 id: attr.template.clone(),
347 inventoryable: false,
348 route_base: queued.route_base.clone(),
349 });
350 }
351 Some(Fragment::Route(route_frag)) => {
352 let is_selected = matched_route
353 .as_ref()
354 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
355 if is_selected && !route_frag.fragment_id.is_empty() {
356 let child_route_base = if let Some((_, ref rm)) = matched_route {
358 if let Some(path) = request_path {
359 route_matcher::compute_route_base(path, rm.consumed_segments)
360 } else {
361 queued.route_base.clone()
362 }
363 } else {
364 queued.route_base.clone()
365 };
366
367 stack.push(QueuedFragment {
368 id: route_frag.fragment_id.clone(),
369 inventoryable: protocol
370 .components
371 .get(&route_frag.fragment_id)
372 .is_some_and(has_template_payload),
373 route_base: child_route_base.clone(),
374 });
375
376 collect_route_boundary_components(
384 std::slice::from_ref(route_frag),
385 &child_route_base,
386 protocol,
387 &mut stack,
388 );
389
390 if !route_frag.children.is_empty() {
394 if let Some(path) = request_path {
395 walk_route_children(
396 &route_frag.children,
397 &child_route_base,
398 &mut stack,
399 &mut ChildWalkCtx {
400 request_path: path,
401 protocol,
402 cache,
403 },
404 );
405 }
406 }
407 }
408 }
409 _ => {}
410 }
411 }
412 }
413
414 component_ids
415}
416
417fn select_best_child_route(
422 children: &[WebUIFragmentRoute],
423 request_path: &str,
424 route_base: &str,
425 cache: &mut CompiledRouteCache,
426) -> Option<(usize, route_matcher::RouteMatch)> {
427 let request_segments = route_matcher::split_request_path(request_path);
428 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
429 for (idx, child) in children.iter().enumerate() {
430 let resolved = route_matcher::resolve_route_path_cow(&child.path, route_base);
431 if let Some(m) = route_matcher::match_route_cached_with_segments(
432 cache,
433 resolved.as_ref(),
434 &request_segments,
435 child.exact,
436 ) {
437 let is_better = best
438 .as_ref()
439 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
440 if is_better {
441 best = Some((idx, m));
442 }
443 }
444 }
445 best
446}
447
448fn walk_route_children(
451 children: &[WebUIFragmentRoute],
452 route_base: &str,
453 stack: &mut Vec<QueuedFragment>,
454 ctx: &mut ChildWalkCtx<'_>,
455) {
456 let mut current = children;
457 let mut base = route_base.to_string();
458
459 loop {
460 let Some((idx, ref rm)) =
461 select_best_child_route(current, ctx.request_path, &base, ctx.cache)
462 else {
463 break;
464 };
465 let matched = ¤t[idx];
466 if matched.fragment_id.is_empty() {
467 break;
468 }
469
470 let child_base = route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
471
472 stack.push(QueuedFragment {
473 id: matched.fragment_id.clone(),
474 inventoryable: ctx
475 .protocol
476 .components
477 .get(&matched.fragment_id)
478 .is_some_and(has_template_payload),
479 route_base: child_base.clone(),
480 });
481
482 if !matched.pending_component.is_empty() {
483 stack.push(QueuedFragment {
484 id: matched.pending_component.clone(),
485 inventoryable: ctx
486 .protocol
487 .components
488 .get(&matched.pending_component)
489 .is_some_and(has_template_payload),
490 route_base: child_base.clone(),
491 });
492 }
493 if !matched.error_component.is_empty() {
494 stack.push(QueuedFragment {
495 id: matched.error_component.clone(),
496 inventoryable: ctx
497 .protocol
498 .components
499 .get(&matched.error_component)
500 .is_some_and(has_template_payload),
501 route_base: child_base.clone(),
502 });
503 }
504
505 if matched.children.is_empty() {
506 break;
507 }
508 current = &matched.children;
509 base = child_base;
510 }
511}
512
513fn collect_route_boundary_components(
522 routes: &[WebUIFragmentRoute],
523 route_base: &str,
524 protocol: &WebUIProtocol,
525 stack: &mut Vec<QueuedFragment>,
526) {
527 let mut remaining: Vec<&WebUIFragmentRoute> = routes.iter().collect();
528 while let Some(route) = remaining.pop() {
529 for component in [&route.pending_component, &route.error_component] {
530 if component.is_empty() {
531 continue;
532 }
533 stack.push(QueuedFragment {
534 id: component.clone(),
535 inventoryable: protocol
536 .components
537 .get(component)
538 .is_some_and(has_template_payload),
539 route_base: route_base.to_string(),
540 });
541 }
542 remaining.extend(route.children.iter());
543 }
544}
545
546pub fn collect_nested_route_params(
551 protocol: &WebUIProtocol,
552 entry_id: &str,
553 request_path: &str,
554 cache: &mut CompiledRouteCache,
555) -> HashMap<String, String> {
556 let mut all_params = HashMap::new();
557 let mut visited_fragments = HashSet::new();
558 let mut stack = vec![QueuedFragment {
559 id: entry_id.to_string(),
560 inventoryable: false,
561 route_base: "/".to_string(),
562 }];
563
564 while let Some(queued) = stack.pop() {
565 if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
566 continue;
567 }
568
569 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
570 continue;
571 };
572
573 let matched_route = route_renderer::find_best_route_match(
574 &frag_list.fragments,
575 request_path,
576 &queued.route_base,
577 cache,
578 );
579
580 for frag in &frag_list.fragments {
581 match frag.fragment.as_ref() {
582 Some(Fragment::Component(component)) => {
583 stack.push(QueuedFragment {
584 id: component.fragment_id.clone(),
585 inventoryable: false,
586 route_base: queued.route_base.clone(),
587 });
588 }
589 Some(Fragment::Route(route_frag)) => {
590 let is_selected = matched_route
591 .as_ref()
592 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
593 if is_selected && !route_frag.fragment_id.is_empty() {
594 if let Some((_, ref rm)) = matched_route {
595 all_params.extend(rm.params.clone());
597
598 let child_route_base = route_matcher::compute_route_base(
599 request_path,
600 rm.consumed_segments,
601 );
602
603 stack.push(QueuedFragment {
604 id: route_frag.fragment_id.clone(),
605 inventoryable: false,
606 route_base: child_route_base.clone(),
607 });
608
609 collect_params_from_children(
611 &route_frag.children,
612 request_path,
613 &child_route_base,
614 &mut all_params,
615 cache,
616 );
617 }
618 }
619 }
620 _ => {}
621 }
622 }
623 }
624
625 all_params
626}
627
628fn collect_params_from_children(
630 children: &[WebUIFragmentRoute],
631 request_path: &str,
632 route_base: &str,
633 all_params: &mut HashMap<String, String>,
634 cache: &mut CompiledRouteCache,
635) {
636 let mut current = children;
637 let mut base = route_base.to_string();
638
639 loop {
640 let Some((idx, rm)) = select_best_child_route(current, request_path, &base, cache) else {
641 break;
642 };
643 all_params.extend(rm.params);
644 let matched = ¤t[idx];
645 if matched.children.is_empty() {
646 break;
647 }
648 let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
649 current = &matched.children;
650 base = child_base;
651 }
652}
653
654fn resolve_tag_templates(templates: &[String], params: &HashMap<String, String>) -> Vec<String> {
662 let mut resolved = Vec::with_capacity(templates.len());
663 for template in templates {
664 if !template.contains('{') {
665 resolved.push(template.clone());
666 continue;
667 }
668 let mut result = String::with_capacity(template.len());
669 let mut rest = template.as_str();
670 while let Some(open) = rest.find('{') {
671 result.push_str(&rest[..open]);
672 rest = &rest[open + 1..];
673 if let Some(close) = rest.find('}') {
674 let name = &rest[..close];
675 if let Some(value) = params.get(name) {
676 result.push_str(value);
677 } else {
678 result.push('{');
679 result.push_str(name);
680 result.push('}');
681 }
682 rest = &rest[close + 1..];
683 } else {
684 result.push('{');
685 }
686 }
687 result.push_str(rest);
688 resolved.push(result);
689 }
690 resolved
691}
692
693fn collect_inventory_and_chain(
697 protocol: &WebUIProtocol,
698 entry_id: &str,
699 request_path: &str,
700 index: &mut ProtocolIndex,
701) -> (Vec<String>, Vec<RouteChainEntry>) {
702 let mut visited_fragments = HashSet::new();
703 let mut seen_components = HashSet::new();
708 let mut component_ids: Vec<String> = Vec::new();
709 let mut chain = Vec::new();
710 let mut stack = vec![QueuedFragment {
711 id: entry_id.to_string(),
712 inventoryable: false,
713 route_base: "/".to_string(),
714 }];
715
716 while let Some(queued) = stack.pop() {
717 if queued.id.is_empty() {
718 continue;
719 }
720
721 if queued.inventoryable && seen_components.insert(queued.id.clone()) {
722 component_ids.push(queued.id.clone());
723 }
724
725 if !visited_fragments.insert(queued.id.clone()) {
726 continue;
727 }
728
729 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
730 continue;
731 };
732
733 let matched_route = route_renderer::find_best_route_match(
734 &frag_list.fragments,
735 request_path,
736 &queued.route_base,
737 &mut index.route_cache,
738 );
739
740 for frag in &frag_list.fragments {
741 match frag.fragment.as_ref() {
742 Some(Fragment::Component(component)) => {
743 stack.push(QueuedFragment {
745 id: component.fragment_id.clone(),
746 inventoryable: true,
747 route_base: queued.route_base.clone(),
748 });
749 }
750 Some(Fragment::ForLoop(for_loop)) => {
751 stack.push(QueuedFragment {
753 id: for_loop.fragment_id.clone(),
754 inventoryable: false,
755 route_base: queued.route_base.clone(),
756 });
757 }
758 Some(Fragment::IfCond(if_cond)) => {
759 stack.push(QueuedFragment {
760 id: if_cond.fragment_id.clone(),
761 inventoryable: false,
762 route_base: queued.route_base.clone(),
763 });
764 }
765 Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
766 stack.push(QueuedFragment {
767 id: attr.template.clone(),
768 inventoryable: false,
769 route_base: queued.route_base.clone(),
770 });
771 }
772 Some(Fragment::Route(route_frag)) => {
773 let is_selected = matched_route
774 .as_ref()
775 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
776 if is_selected && !route_frag.fragment_id.is_empty() {
777 if let Some((_, ref rm)) = matched_route {
778 chain.push(RouteChainEntry {
780 component: route_frag.fragment_id.clone(),
781 path: route_frag.path.clone(),
782 params: rm.params.clone(),
783 exact: route_frag.exact,
784 allowed_query: route_frag.allowed_query.clone(),
785 keep_alive: route_frag.keep_alive,
786 cache_tags: route_frag.cache_tags.clone(),
787 invalidates: route_frag.invalidates.clone(),
788 pending_component: route_frag.pending_component.clone(),
789 error_component: route_frag.error_component.clone(),
790 });
791
792 let child_route_base = route_matcher::compute_route_base(
793 request_path,
794 rm.consumed_segments,
795 );
796
797 let is_inventoryable = protocol
799 .components
800 .get(&route_frag.fragment_id)
801 .is_some_and(has_template_payload);
802 stack.push(QueuedFragment {
803 id: route_frag.fragment_id.clone(),
804 inventoryable: is_inventoryable,
805 route_base: child_route_base.clone(),
806 });
807
808 collect_route_boundary_components(
809 std::slice::from_ref(route_frag),
810 &child_route_base,
811 protocol,
812 &mut stack,
813 );
814
815 if !route_frag.children.is_empty() {
817 walk_children_for_inventory_and_chain(
818 &route_frag.children,
819 &child_route_base,
820 &mut stack,
821 &mut chain,
822 &mut ChildWalkCtx {
823 request_path,
824 protocol,
825 cache: &mut index.route_cache,
826 },
827 );
828 }
829 }
830 }
831 }
832 _ => {}
833 }
834 }
835 }
836
837 (component_ids, chain)
838}
839
840fn walk_children_for_inventory_and_chain(
842 children: &[WebUIFragmentRoute],
843 route_base: &str,
844 stack: &mut Vec<QueuedFragment>,
845 chain: &mut Vec<RouteChainEntry>,
846 ctx: &mut ChildWalkCtx<'_>,
847) {
848 let mut current = children;
849 let mut base = route_base.to_string();
850
851 loop {
852 let Some((idx, ref rm)) =
853 select_best_child_route(current, ctx.request_path, &base, ctx.cache)
854 else {
855 break;
856 };
857 let matched = ¤t[idx];
858 if matched.fragment_id.is_empty() {
859 break;
860 }
861
862 let child_base = route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
863
864 chain.push(RouteChainEntry {
865 component: matched.fragment_id.clone(),
866 path: matched.path.clone(),
867 params: rm.params.clone(),
868 exact: matched.exact,
869 allowed_query: matched.allowed_query.clone(),
870 keep_alive: matched.keep_alive,
871 cache_tags: matched.cache_tags.clone(),
872 invalidates: matched.invalidates.clone(),
873 pending_component: matched.pending_component.clone(),
874 error_component: matched.error_component.clone(),
875 });
876
877 stack.push(QueuedFragment {
878 id: matched.fragment_id.clone(),
879 inventoryable: ctx
880 .protocol
881 .components
882 .get(&matched.fragment_id)
883 .is_some_and(has_template_payload),
884 route_base: child_base.clone(),
885 });
886
887 if matched.children.is_empty() {
888 break;
889 }
890 current = &matched.children;
891 base = child_base;
892 }
893}
894
895pub fn render_partial(
911 protocol: &WebUIProtocol,
912 entry_id: &str,
913 request_path: &str,
914 inventory_hex: &str,
915 index: &mut ProtocolIndex,
916) -> Result<Value, HandlerError> {
917 let (component_ids, mut chain) =
919 collect_inventory_and_chain(protocol, entry_id, request_path, index);
920
921 let (needed_names, updated_inv) =
922 filter_needed_components(&component_ids, inventory_hex, &index.component_index)?;
923
924 let mut accumulated_params: HashMap<String, String> = HashMap::new();
926 let mut all_resolved_tags: Vec<String> = Vec::new();
927
928 for entry in &mut chain {
929 for (k, v) in &entry.params {
930 accumulated_params.insert(k.clone(), v.clone());
931 }
932 entry.cache_tags = resolve_tag_templates(&entry.cache_tags, &accumulated_params);
933 entry.invalidates = resolve_tag_templates(&entry.invalidates, &accumulated_params);
934 all_resolved_tags.extend(entry.cache_tags.iter().cloned());
935 }
936
937 let tag_refs: Vec<&str> = needed_names.iter().map(|s| s.as_str()).collect();
938 let assets = collect_component_assets(protocol, &tag_refs, index)?;
939
940 let chain_array = Value::Array(chain.iter().map(RouteChainEntry::to_json).collect());
941
942 let mut result = serde_json::Map::with_capacity(7);
943 result.insert("templateStyles".into(), Value::Array(assets.styles));
944 result.insert("templates".into(), Value::Object(assets.templates));
945 result.insert("templateFunctions".into(), Value::Object(assets.functions));
946 result.insert("inventory".into(), Value::String(updated_inv));
947 result.insert("path".into(), Value::String(request_path.to_string()));
948 result.insert("chain".into(), chain_array);
949 if !all_resolved_tags.is_empty() {
950 let mut seen = HashSet::new();
951 let deduped: Vec<Value> = all_resolved_tags
952 .into_iter()
953 .filter(|t| seen.insert(t.clone()))
954 .map(Value::String)
955 .collect();
956 result.insert("cacheTags".into(), Value::Array(deduped));
957 }
958 Ok(Value::Object(result))
959}
960
961pub fn render_action_response(
972 protocol: &WebUIProtocol,
973 state: Value,
974 entry_id: &str,
975 request_path: &str,
976 index: &mut ProtocolIndex,
977) -> Value {
978 let chain = collect_route_chain(protocol, entry_id, request_path, &mut index.route_cache);
979
980 let mut accumulated_params: HashMap<String, String> = HashMap::new();
982 let mut all_invalidates: Vec<String> = Vec::new();
983
984 for entry in &chain {
985 for (k, v) in &entry.params {
986 accumulated_params.insert(k.clone(), v.clone());
987 }
988 let resolved = resolve_tag_templates(&entry.invalidates, &accumulated_params);
989 all_invalidates.extend(resolved);
990 }
991
992 let mut seen = HashSet::new();
994 let deduped: Vec<Value> = all_invalidates
995 .into_iter()
996 .filter(|t| seen.insert(t.clone()))
997 .map(Value::String)
998 .collect();
999
1000 let mut result = serde_json::Map::with_capacity(3);
1001 result.insert("state".into(), state);
1002 result.insert("invalidateTags".into(), Value::Array(deduped));
1003 result.insert("path".into(), Value::String(request_path.to_string()));
1004 Value::Object(result)
1005}
1006
1007pub fn render_component_templates(
1016 protocol: &WebUIProtocol,
1017 component_tags: &[&str],
1018 inventory_hex: &str,
1019 index: &mut ProtocolIndex,
1020) -> Result<Value, HandlerError> {
1021 let mut seen: HashSet<&str> = HashSet::new();
1023 let requested: Vec<String> = component_tags
1024 .iter()
1025 .filter(|s| seen.insert(**s))
1026 .map(|s| (*s).to_string())
1027 .collect();
1028 let (needed, updated_inv) =
1029 filter_needed_components(&requested, inventory_hex, &index.component_index)?;
1030
1031 let tag_refs: Vec<&str> = needed.iter().map(|s| s.as_str()).collect();
1032 let assets = collect_component_assets(protocol, &tag_refs, index)?;
1033
1034 let mut result = serde_json::Map::with_capacity(4);
1035 result.insert("templateStyles".into(), Value::Array(assets.styles));
1036 result.insert("templates".into(), Value::Object(assets.templates));
1037 result.insert("templateFunctions".into(), Value::Object(assets.functions));
1038 result.insert("inventory".into(), Value::String(updated_inv));
1039 Ok(Value::Object(result))
1040}
1041
1042fn collect_component_assets(
1044 protocol: &WebUIProtocol,
1045 tags: &[&str],
1046 index: &mut ProtocolIndex,
1047) -> Result<ComponentAssets, HandlerError> {
1048 let mut style_array = Vec::new();
1049 let mut tmpl_map = serde_json::Map::new();
1050 let mut function_map = serde_json::Map::new();
1051
1052 let mut sorted_tags: Vec<&str> = tags.to_vec();
1054 sorted_tags.sort_unstable();
1055
1056 for tag in sorted_tags {
1057 let Some(component) = protocol.components.get(tag) else {
1058 continue;
1059 };
1060 if !has_template_payload(component) {
1061 continue;
1062 }
1063 if !component.css.is_empty() {
1064 let tag_html = crate::css_module::build_importmap_tag(tag, &component.css, None);
1068 style_array.push(Value::String(tag_html));
1069 }
1070 if !component.template_json.is_empty() {
1071 let template_value = cached_template_metadata(index, tag, &component.template_json)?;
1072 tmpl_map.insert(tag.to_string(), template_value);
1073 if !component.template_functions.is_empty() {
1074 function_map.insert(
1075 tag.to_string(),
1076 Value::String(component.template_functions.clone()),
1077 );
1078 }
1079 } else {
1080 tmpl_map.insert(tag.to_string(), Value::String(component.template.clone()));
1081 }
1082 }
1083
1084 Ok(ComponentAssets {
1085 styles: style_array,
1086 templates: tmpl_map,
1087 functions: function_map,
1088 })
1089}
1090
1091fn cached_template_metadata(
1092 index: &mut ProtocolIndex,
1093 tag: &str,
1094 template_json: &str,
1095) -> Result<Value, HandlerError> {
1096 if let Some(value) = index.template_metadata_cache.get(tag) {
1097 return Ok(value.clone());
1098 }
1099
1100 let value: Value = serde_json::from_str(template_json).map_err(|error| {
1101 HandlerError::Rendering(format!(
1102 "failed to parse template metadata for {tag}: {error}"
1103 ))
1104 })?;
1105 index
1106 .template_metadata_cache
1107 .insert(tag.to_string(), value.clone());
1108 Ok(value)
1109}
1110
1111#[derive(Debug, Clone)]
1115pub struct RouteChainEntry {
1116 pub component: String,
1118 pub path: String,
1120 pub params: HashMap<String, String>,
1122 pub exact: bool,
1124 pub allowed_query: String,
1126 pub keep_alive: bool,
1128 pub cache_tags: Vec<String>,
1130 pub invalidates: Vec<String>,
1132 pub pending_component: String,
1134 pub error_component: String,
1136}
1137
1138impl RouteChainEntry {
1139 #[must_use]
1141 pub fn to_json(&self) -> Value {
1142 let mut obj = serde_json::Map::with_capacity(9);
1143 obj.insert("component".into(), Value::String(self.component.clone()));
1144 obj.insert("path".into(), Value::String(self.path.clone()));
1145 if !self.params.is_empty() {
1146 let params_obj: serde_json::Map<String, Value> = self
1147 .params
1148 .iter()
1149 .map(|(k, v)| (k.clone(), Value::String(v.clone())))
1150 .collect();
1151 obj.insert("params".into(), Value::Object(params_obj));
1152 }
1153 if self.exact {
1154 obj.insert("exact".into(), Value::Bool(true));
1155 }
1156 if !self.allowed_query.is_empty() {
1157 obj.insert(
1158 "allowedQuery".into(),
1159 Value::String(self.allowed_query.clone()),
1160 );
1161 }
1162 if self.keep_alive {
1163 obj.insert("keepAlive".into(), Value::Bool(true));
1164 }
1165 if !self.pending_component.is_empty() {
1166 obj.insert(
1167 "pendingComponent".into(),
1168 Value::String(self.pending_component.clone()),
1169 );
1170 }
1171 if !self.error_component.is_empty() {
1172 obj.insert(
1173 "errorComponent".into(),
1174 Value::String(self.error_component.clone()),
1175 );
1176 }
1177 if !self.invalidates.is_empty() {
1178 obj.insert(
1179 "invalidates".into(),
1180 Value::Array(
1181 self.invalidates
1182 .iter()
1183 .map(|s| Value::String(s.clone()))
1184 .collect(),
1185 ),
1186 );
1187 }
1188 Value::Object(obj)
1189 }
1190}
1191
1192pub fn collect_route_chain(
1197 protocol: &WebUIProtocol,
1198 entry_id: &str,
1199 request_path: &str,
1200 cache: &mut CompiledRouteCache,
1201) -> Vec<RouteChainEntry> {
1202 let mut chain = Vec::new();
1203 let mut visited_fragments = HashSet::new();
1204 let mut stack = vec![QueuedFragment {
1205 id: entry_id.to_string(),
1206 inventoryable: false,
1207 route_base: "/".to_string(),
1208 }];
1209
1210 while let Some(queued) = stack.pop() {
1211 if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
1212 continue;
1213 }
1214
1215 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1216 continue;
1217 };
1218
1219 let matched_route = route_renderer::find_best_route_match(
1220 &frag_list.fragments,
1221 request_path,
1222 &queued.route_base,
1223 cache,
1224 );
1225
1226 for frag in &frag_list.fragments {
1227 match frag.fragment.as_ref() {
1228 Some(Fragment::Component(component)) => {
1229 stack.push(QueuedFragment {
1230 id: component.fragment_id.clone(),
1231 inventoryable: false,
1232 route_base: queued.route_base.clone(),
1233 });
1234 }
1235 Some(Fragment::Route(route_frag)) => {
1236 let is_selected = matched_route
1237 .as_ref()
1238 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1239 if is_selected && !route_frag.fragment_id.is_empty() {
1240 if let Some((_, ref rm)) = matched_route {
1241 chain.push(RouteChainEntry {
1242 component: route_frag.fragment_id.clone(),
1243 path: route_frag.path.clone(),
1244 params: rm.params.clone(),
1245 exact: route_frag.exact,
1246 allowed_query: route_frag.allowed_query.clone(),
1247 keep_alive: route_frag.keep_alive,
1248 cache_tags: route_frag.cache_tags.clone(),
1249 invalidates: route_frag.invalidates.clone(),
1250 pending_component: route_frag.pending_component.clone(),
1251 error_component: route_frag.error_component.clone(),
1252 });
1253
1254 let child_route_base = route_matcher::compute_route_base(
1255 request_path,
1256 rm.consumed_segments,
1257 );
1258
1259 stack.push(QueuedFragment {
1260 id: route_frag.fragment_id.clone(),
1261 inventoryable: false,
1262 route_base: child_route_base.clone(),
1263 });
1264
1265 collect_chain_from_children(
1267 &route_frag.children,
1268 request_path,
1269 &child_route_base,
1270 &mut chain,
1271 cache,
1272 );
1273 }
1274 }
1275 }
1276 _ => {}
1277 }
1278 }
1279 }
1280
1281 chain
1282}
1283
1284fn collect_chain_from_children(
1286 children: &[WebUIFragmentRoute],
1287 request_path: &str,
1288 route_base: &str,
1289 chain: &mut Vec<RouteChainEntry>,
1290 cache: &mut CompiledRouteCache,
1291) {
1292 let mut pending: Vec<(&[WebUIFragmentRoute], String)> =
1293 vec![(children, route_base.to_string())];
1294
1295 while let Some((current, base)) = pending.pop() {
1296 if let Some((idx, rm)) = select_best_child_route(current, request_path, &base, cache) {
1297 let matched = ¤t[idx];
1298 chain.push(RouteChainEntry {
1299 component: matched.fragment_id.clone(),
1300 path: matched.path.clone(),
1301 params: rm.params,
1302 exact: matched.exact,
1303 allowed_query: matched.allowed_query.clone(),
1304 keep_alive: matched.keep_alive,
1305 cache_tags: matched.cache_tags.clone(),
1306 invalidates: matched.invalidates.clone(),
1307 pending_component: matched.pending_component.clone(),
1308 error_component: matched.error_component.clone(),
1309 });
1310 if !matched.children.is_empty() {
1311 let child_base =
1312 route_matcher::compute_route_base(request_path, rm.consumed_segments);
1313 pending.push((&matched.children, child_base));
1314 }
1315 }
1316 }
1317}
1318
1319#[cfg(test)]
1320mod tests {
1321 use super::*;
1322 use std::collections::HashMap;
1323 use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
1324
1325 #[test]
1326 fn test_parse_encode_inventory_roundtrip() {
1327 let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
1328 let hex = encode_inventory(&original);
1329 let decoded = parse_inventory(&hex).unwrap();
1330 assert_eq!(original, decoded);
1331 }
1332
1333 #[test]
1334 fn test_parse_inventory_empty() {
1335 let result = parse_inventory("").unwrap();
1336 assert!(result.is_empty());
1337 }
1338
1339 #[test]
1340 fn test_parse_inventory_rejects_odd_length() {
1341 let result = parse_inventory("abc");
1342 assert!(result.is_err());
1343 }
1344
1345 #[test]
1346 fn test_parse_inventory_rejects_invalid_hex() {
1347 let result = parse_inventory("zz");
1348 assert!(result.is_err());
1349 }
1350
1351 #[test]
1352 fn test_bitfield_set_and_check() {
1353 let mut inv = vec![0u8; 4];
1354 set_component(&mut inv, 0);
1355 set_component(&mut inv, 7);
1356 set_component(&mut inv, 8);
1357 set_component(&mut inv, 15);
1358 assert!(has_component(&inv, 0));
1359 assert!(has_component(&inv, 7));
1360 assert!(has_component(&inv, 8));
1361 assert!(has_component(&inv, 15));
1362 assert!(!has_component(&inv, 1));
1363 assert!(!has_component(&inv, 9));
1364 }
1365
1366 #[test]
1367 fn test_filter_needed_components_no_false_positives() {
1368 let mut index = HashMap::new();
1370 index.insert("email-message".to_string(), 0);
1371 index.insert("o-button".to_string(), 1);
1372 index.insert("o-avatar".to_string(), 2);
1373
1374 let names = vec!["email-message".to_string(), "o-button".to_string()];
1375
1376 let mut inv = vec![0u8; 1];
1378 set_component(&mut inv, 1); let inv_hex = encode_inventory(&inv);
1380
1381 let (needed, _) = filter_needed_components(&names, &inv_hex, &index).unwrap();
1382 assert_eq!(needed.len(), 1);
1383 assert!(
1384 needed.contains(&"email-message".to_string()),
1385 "email-message must be needed: {needed:?}"
1386 );
1387 }
1388
1389 #[test]
1390 fn test_get_needed_components_empty_inventory() {
1391 let mut fragments = HashMap::new();
1392 fragments.insert(
1393 "app-shell".to_string(),
1394 FragmentList {
1395 fragments: vec![WebUIFragment::component("my-card")],
1396 },
1397 );
1398 fragments.insert(
1399 "my-card".to_string(),
1400 FragmentList {
1401 fragments: vec![WebUIFragment::raw("card")],
1402 },
1403 );
1404
1405 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1406 let comp_index = build_component_index(&protocol);
1407 let (needed, _inv) =
1408 get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
1409 assert!(needed.contains(&"app-shell".to_string()));
1410 assert!(needed.contains(&"my-card".to_string()));
1411 }
1412
1413 #[test]
1414 fn test_get_needed_components_skips_inventory() {
1415 let mut fragments = HashMap::new();
1416 fragments.insert(
1417 "app-shell".to_string(),
1418 FragmentList {
1419 fragments: vec![WebUIFragment::component("my-card")],
1420 },
1421 );
1422 fragments.insert(
1423 "my-card".to_string(),
1424 FragmentList {
1425 fragments: vec![WebUIFragment::raw("card")],
1426 },
1427 );
1428
1429 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1430 protocol
1431 .components
1432 .entry("app-shell".to_string())
1433 .or_default()
1434 .template = "<t></t>".to_string();
1435 protocol
1436 .components
1437 .entry("my-card".to_string())
1438 .or_default()
1439 .template = "<t></t>".to_string();
1440
1441 let comp_index = build_component_index(&protocol);
1442 let (_needed, inv_hex) =
1443 get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
1444 let (needed2, _) =
1445 get_needed_components(&protocol, "app-shell", &inv_hex, &comp_index).unwrap();
1446 assert!(needed2.is_empty());
1447 }
1448
1449 #[test]
1450 fn test_get_needed_components_skips_control_fragments() {
1451 let mut fragments = HashMap::new();
1452 fragments.insert(
1453 "app-shell".to_string(),
1454 FragmentList {
1455 fragments: vec![
1456 WebUIFragment::if_cond(
1457 webui_protocol::ConditionExpr::identifier("showNav"),
1458 "if-shell",
1459 ),
1460 WebUIFragment::for_loop("item", "items", "for-items"),
1461 WebUIFragment::attribute_template("title", "attr-title"),
1462 ],
1463 },
1464 );
1465 fragments.insert(
1466 "if-shell".to_string(),
1467 FragmentList {
1468 fragments: vec![WebUIFragment::component("mp-category-nav")],
1469 },
1470 );
1471 fragments.insert(
1472 "for-items".to_string(),
1473 FragmentList {
1474 fragments: vec![WebUIFragment::component("mp-product-card")],
1475 },
1476 );
1477 fragments.insert(
1478 "attr-title".to_string(),
1479 FragmentList {
1480 fragments: vec![WebUIFragment::raw("Products")],
1481 },
1482 );
1483 fragments.insert(
1484 "mp-category-nav".to_string(),
1485 FragmentList {
1486 fragments: vec![WebUIFragment::raw("<nav></nav>")],
1487 },
1488 );
1489 fragments.insert(
1490 "mp-product-card".to_string(),
1491 FragmentList {
1492 fragments: vec![WebUIFragment::raw("<article></article>")],
1493 },
1494 );
1495
1496 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1497 let comp_index = build_component_index(&protocol);
1498 let needed: HashSet<String> =
1499 get_needed_components(&protocol, "app-shell", "", &comp_index)
1500 .unwrap()
1501 .0
1502 .into_iter()
1503 .collect();
1504
1505 assert!(needed.contains("app-shell"));
1506 assert!(needed.contains("mp-category-nav"));
1507 assert!(needed.contains("mp-product-card"));
1508 assert!(!needed.contains("if-shell"));
1509 assert!(!needed.contains("for-items"));
1510 assert!(!needed.contains("attr-title"));
1511 }
1512
1513 #[test]
1514 fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
1515 let mut fragments = HashMap::new();
1516 fragments.insert(
1517 "index.html".to_string(),
1518 FragmentList {
1519 fragments: vec![WebUIFragment::component("mp-app")],
1520 },
1521 );
1522 fragments.insert(
1523 "mp-app".to_string(),
1524 FragmentList {
1525 fragments: vec![
1526 WebUIFragment::component("mp-category-nav"),
1527 WebUIFragment::route_from(WebUiFragmentRoute {
1528 path: "/search/:category".into(),
1529 fragment_id: "mp-search-page".into(),
1530 exact: true,
1531 keep_alive: false,
1532 ..Default::default()
1533 }),
1534 WebUIFragment::route_from(WebUiFragmentRoute {
1535 path: "/product/:handle".into(),
1536 fragment_id: "mp-product-page".into(),
1537 exact: true,
1538 keep_alive: false,
1539 ..Default::default()
1540 }),
1541 ],
1542 },
1543 );
1544 fragments.insert(
1545 "mp-category-nav".to_string(),
1546 FragmentList {
1547 fragments: vec![WebUIFragment::raw("<nav></nav>")],
1548 },
1549 );
1550 fragments.insert(
1551 "mp-search-page".to_string(),
1552 FragmentList {
1553 fragments: vec![WebUIFragment::component("mp-product-grid")],
1554 },
1555 );
1556 fragments.insert(
1557 "mp-product-grid".to_string(),
1558 FragmentList {
1559 fragments: vec![WebUIFragment::raw("<div>grid</div>")],
1560 },
1561 );
1562 fragments.insert(
1563 "mp-product-page".to_string(),
1564 FragmentList {
1565 fragments: vec![WebUIFragment::component("mp-product-detail")],
1566 },
1567 );
1568 fragments.insert(
1569 "mp-product-detail".to_string(),
1570 FragmentList {
1571 fragments: vec![WebUIFragment::raw("<div>detail</div>")],
1572 },
1573 );
1574
1575 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1576 protocol
1577 .components
1578 .entry("mp-search-page".to_string())
1579 .or_default()
1580 .template = "<mp-search-page></mp-search-page>".to_string();
1581 protocol
1582 .components
1583 .entry("mp-product-page".to_string())
1584 .or_default()
1585 .template = "<mp-product-page></mp-product-page>".to_string();
1586
1587 let comp_index = build_component_index(&protocol);
1588 let (needed, _inv) = get_needed_components_for_request(
1589 &protocol,
1590 "index.html",
1591 "/search/shirts",
1592 "",
1593 &comp_index,
1594 )
1595 .unwrap();
1596 let needed: HashSet<String> = needed.into_iter().collect();
1597
1598 assert!(needed.contains("mp-app"));
1599 assert!(needed.contains("mp-category-nav"));
1600 assert!(needed.contains("mp-search-page"));
1601 assert!(needed.contains("mp-product-grid"));
1602 assert!(!needed.contains("mp-product-page"));
1603 assert!(!needed.contains("mp-product-detail"));
1604 }
1605
1606 #[test]
1607 fn test_get_needed_components_for_request_follows_nested_route_chain() {
1608 let mut fragments = HashMap::new();
1609 fragments.insert(
1610 "index.html".to_string(),
1611 FragmentList {
1612 fragments: vec![WebUIFragment::component("mp-app")],
1613 },
1614 );
1615 fragments.insert(
1616 "mp-app".to_string(),
1617 FragmentList {
1618 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1619 path: "/account/*rest".into(),
1620 fragment_id: "mp-account-shell".into(),
1621 exact: false,
1622 keep_alive: false,
1623 ..Default::default()
1624 })],
1625 },
1626 );
1627 fragments.insert(
1628 "mp-account-shell".to_string(),
1629 FragmentList {
1630 fragments: vec![
1631 WebUIFragment::component("mp-account-nav"),
1632 WebUIFragment::route_from(WebUiFragmentRoute {
1633 path: "/account/profile".into(),
1634 fragment_id: "mp-profile-page".into(),
1635 exact: true,
1636 keep_alive: false,
1637 ..Default::default()
1638 }),
1639 WebUIFragment::route_from(WebUiFragmentRoute {
1640 path: "/account/orders/:id".into(),
1641 fragment_id: "mp-order-page".into(),
1642 exact: true,
1643 keep_alive: false,
1644 ..Default::default()
1645 }),
1646 ],
1647 },
1648 );
1649 fragments.insert(
1650 "mp-account-nav".to_string(),
1651 FragmentList {
1652 fragments: vec![WebUIFragment::raw("<nav></nav>")],
1653 },
1654 );
1655 fragments.insert(
1656 "mp-profile-page".to_string(),
1657 FragmentList {
1658 fragments: vec![WebUIFragment::raw("<profile></profile>")],
1659 },
1660 );
1661 fragments.insert(
1662 "mp-order-page".to_string(),
1663 FragmentList {
1664 fragments: vec![WebUIFragment::component("mp-order-detail")],
1665 },
1666 );
1667 fragments.insert(
1668 "mp-order-detail".to_string(),
1669 FragmentList {
1670 fragments: vec![WebUIFragment::raw("<detail></detail>")],
1671 },
1672 );
1673
1674 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1675 protocol
1676 .components
1677 .entry("mp-account-shell".to_string())
1678 .or_default()
1679 .template = "<mp-account-shell></mp-account-shell>".to_string();
1680 protocol
1681 .components
1682 .entry("mp-profile-page".to_string())
1683 .or_default()
1684 .template = "<mp-profile-page></mp-profile-page>".to_string();
1685 protocol
1686 .components
1687 .entry("mp-order-page".to_string())
1688 .or_default()
1689 .template = "<mp-order-page></mp-order-page>".to_string();
1690
1691 let comp_index = build_component_index(&protocol);
1692 let (needed, _inv) = get_needed_components_for_request(
1693 &protocol,
1694 "index.html",
1695 "/account/orders/42",
1696 "",
1697 &comp_index,
1698 )
1699 .unwrap();
1700 let needed: HashSet<String> = needed.into_iter().collect();
1701
1702 assert!(needed.contains("mp-app"));
1703 assert!(needed.contains("mp-account-shell"));
1704 assert!(needed.contains("mp-account-nav"));
1705 assert!(needed.contains("mp-order-page"));
1706 assert!(needed.contains("mp-order-detail"));
1707 assert!(!needed.contains("mp-profile-page"));
1708 }
1709
1710 #[test]
1711 fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
1712 let mut fragments = HashMap::new();
1713 fragments.insert(
1714 "index.html".to_string(),
1715 FragmentList {
1716 fragments: vec![WebUIFragment::component("mp-app")],
1717 },
1718 );
1719 fragments.insert(
1720 "mp-app".to_string(),
1721 FragmentList {
1722 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1723 path: "/search".into(),
1724 fragment_id: "mp-search-page".into(),
1725 exact: true,
1726 keep_alive: false,
1727 ..Default::default()
1728 })],
1729 },
1730 );
1731 fragments.insert(
1732 "mp-search-page".to_string(),
1733 FragmentList {
1734 fragments: vec![
1735 WebUIFragment::if_cond(
1736 webui_protocol::ConditionExpr::identifier("showFilters"),
1737 "if-filters",
1738 ),
1739 WebUIFragment::for_loop("item", "items", "item-loop"),
1740 ],
1741 },
1742 );
1743 fragments.insert(
1744 "if-filters".to_string(),
1745 FragmentList {
1746 fragments: vec![WebUIFragment::component("mp-filter-panel")],
1747 },
1748 );
1749 fragments.insert(
1750 "item-loop".to_string(),
1751 FragmentList {
1752 fragments: vec![WebUIFragment::component("mp-item-card")],
1753 },
1754 );
1755 fragments.insert(
1756 "mp-filter-panel".to_string(),
1757 FragmentList {
1758 fragments: vec![WebUIFragment::raw("<filters></filters>")],
1759 },
1760 );
1761 fragments.insert(
1762 "mp-item-card".to_string(),
1763 FragmentList {
1764 fragments: vec![WebUIFragment::raw("<item></item>")],
1765 },
1766 );
1767
1768 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1769 protocol
1770 .components
1771 .entry("mp-search-page".to_string())
1772 .or_default()
1773 .template = "<mp-search-page></mp-search-page>".to_string();
1774
1775 let comp_index = build_component_index(&protocol);
1776 let (needed, _inv) =
1777 get_needed_components_for_request(&protocol, "index.html", "/search", "", &comp_index)
1778 .unwrap();
1779 let needed: HashSet<String> = needed.into_iter().collect();
1780
1781 assert!(needed.contains("mp-app"));
1782 assert!(needed.contains("mp-search-page"));
1783 assert!(needed.contains("mp-filter-panel"));
1784 assert!(needed.contains("mp-item-card"));
1785 }
1786
1787 #[test]
1788 fn test_get_needed_components_for_request_respects_inventory() {
1789 let mut fragments = HashMap::new();
1790 fragments.insert(
1791 "index.html".to_string(),
1792 FragmentList {
1793 fragments: vec![WebUIFragment::component("mp-app")],
1794 },
1795 );
1796 fragments.insert(
1797 "mp-app".to_string(),
1798 FragmentList {
1799 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1800 path: "/search".into(),
1801 fragment_id: "mp-search-page".into(),
1802 exact: true,
1803 keep_alive: false,
1804 ..Default::default()
1805 })],
1806 },
1807 );
1808 fragments.insert(
1809 "mp-search-page".to_string(),
1810 FragmentList {
1811 fragments: vec![WebUIFragment::component("mp-product-grid")],
1812 },
1813 );
1814 fragments.insert(
1815 "mp-product-grid".to_string(),
1816 FragmentList {
1817 fragments: vec![WebUIFragment::raw("<grid></grid>")],
1818 },
1819 );
1820
1821 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1822 for name in ["mp-app", "mp-search-page", "mp-product-grid"] {
1823 protocol
1824 .components
1825 .entry(name.to_string())
1826 .or_default()
1827 .template = format!("<f-template id=\"{name}\"></f-template>");
1828 }
1829
1830 let comp_index = build_component_index(&protocol);
1831 let (_needed, inventory) =
1832 get_needed_components_for_request(&protocol, "index.html", "/search", "", &comp_index)
1833 .unwrap();
1834 let (needed_again, _) = get_needed_components_for_request(
1835 &protocol,
1836 "index.html",
1837 "/search",
1838 &inventory,
1839 &comp_index,
1840 )
1841 .unwrap();
1842 assert!(needed_again.is_empty());
1843 }
1844
1845 #[test]
1846 fn test_inventory_includes_boundary_components_for_unmatched_sibling_routes() {
1847 let mut fragments = HashMap::new();
1853 fragments.insert(
1854 "index.html".to_string(),
1855 FragmentList {
1856 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1857 path: "/".into(),
1858 fragment_id: "route-shell".into(),
1859 children: vec![
1860 WebUiFragmentRoute {
1861 path: "slow".into(),
1862 fragment_id: "page-slow".into(),
1863 exact: true,
1864 pending_component: "loading-skeleton".into(),
1865 ..Default::default()
1866 },
1867 WebUiFragmentRoute {
1868 path: "failing".into(),
1869 fragment_id: "page-failing".into(),
1870 exact: true,
1871 error_component: "error-display".into(),
1872 ..Default::default()
1873 },
1874 ],
1875 ..Default::default()
1876 })],
1877 },
1878 );
1879 for id in [
1880 "route-shell",
1881 "page-slow",
1882 "page-failing",
1883 "loading-skeleton",
1884 "error-display",
1885 ] {
1886 fragments.insert(
1887 id.to_string(),
1888 FragmentList {
1889 fragments: vec![WebUIFragment::raw("<x></x>")],
1890 },
1891 );
1892 }
1893
1894 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1895 for id in [
1896 "route-shell",
1897 "page-slow",
1898 "page-failing",
1899 "loading-skeleton",
1900 "error-display",
1901 ] {
1902 protocol
1903 .components
1904 .entry(id.to_string())
1905 .or_default()
1906 .template = format!("<f-template id=\"{id}\"></f-template>");
1907 }
1908
1909 let reachable = collect_reachable_components_for_request(
1911 &protocol,
1912 "index.html",
1913 "/",
1914 &mut CompiledRouteCache::new(),
1915 );
1916 assert!(
1917 reachable.contains("loading-skeleton"),
1918 "pending component of an unmatched sibling route must be inventoried: {reachable:?}"
1919 );
1920 assert!(
1921 reachable.contains("error-display"),
1922 "error component of an unmatched sibling route must be inventoried: {reachable:?}"
1923 );
1924 }
1925
1926 #[test]
1927 fn test_render_partial_separates_module_styles_from_templates() {
1928 let mut fragments = HashMap::new();
1929 fragments.insert(
1930 "index.html".to_string(),
1931 FragmentList {
1932 fragments: vec![WebUIFragment::component("my-page")],
1933 },
1934 );
1935 fragments.insert(
1936 "my-page".to_string(),
1937 FragmentList {
1938 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1939 },
1940 );
1941
1942 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1943 let component = protocol
1944 .components
1945 .entry("my-page".to_string())
1946 .or_default();
1947 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
1948 component.css = ".page{color:red}".to_string();
1949
1950 let mut index = ProtocolIndex::new(&protocol);
1951 let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
1952 let styles = partial["templateStyles"]
1953 .as_array()
1954 .expect("templateStyles should be an array");
1955 assert_eq!(styles.len(), 1);
1956 let style_html = styles[0].as_str().unwrap_or_default();
1957 assert!(
1958 style_html.starts_with(r#"<script type="importmap""#)
1959 && style_html.contains(r#""my-page":"data:text/css,"#),
1960 "module style entry should be an importmap registering the component specifier: {style_html}"
1961 );
1962 assert!(
1963 style_html.contains(".page{color:red}"),
1964 "module style entry should contain the CSS content verbatim inside the data: URI: {style_html}"
1965 );
1966
1967 let templates = partial["templates"]
1969 .as_object()
1970 .expect("templates should be an object");
1971 assert_eq!(templates.len(), 1);
1972 let template = templates.get("my-page").expect("my-page template");
1973 assert_eq!(template["h"], "<p>page</p>");
1974 }
1975
1976 #[test]
1977 fn test_render_partial_link_strategy_has_empty_template_styles() {
1978 let mut fragments = HashMap::new();
1981 fragments.insert(
1982 "index.html".to_string(),
1983 FragmentList {
1984 fragments: vec![WebUIFragment::component("my-page")],
1985 },
1986 );
1987 fragments.insert(
1988 "my-page".to_string(),
1989 FragmentList {
1990 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1991 },
1992 );
1993
1994 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1995 let component = protocol
1996 .components
1997 .entry("my-page".to_string())
1998 .or_default();
1999 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
2000 component.css_href = "my-page.css".to_string();
2001 let mut index = ProtocolIndex::new(&protocol);
2004 let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
2005 let styles = partial["templateStyles"]
2006 .as_array()
2007 .expect("templateStyles should be an array");
2008 let templates = partial["templates"]
2009 .as_object()
2010 .expect("templates should be an object");
2011
2012 assert!(
2013 styles.is_empty(),
2014 "Link strategy should produce empty templateStyles: {styles:?}"
2015 );
2016 assert_eq!(templates.len(), 1, "should include template metadata");
2017 }
2018
2019 #[test]
2020 fn test_render_partial_style_strategy_has_empty_template_styles() {
2021 let mut fragments = HashMap::new();
2025 fragments.insert(
2026 "index.html".to_string(),
2027 FragmentList {
2028 fragments: vec![WebUIFragment::component("my-page")],
2029 },
2030 );
2031 fragments.insert(
2032 "my-page".to_string(),
2033 FragmentList {
2034 fragments: vec![WebUIFragment::raw("<p>page</p>")],
2035 },
2036 );
2037
2038 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2039 let component = protocol
2040 .components
2041 .entry("my-page".to_string())
2042 .or_default();
2043 component.template_json = r#"{"h":"<style>.p{color:red}</style><p>page</p>"}"#.to_string();
2045 let mut index = ProtocolIndex::new(&protocol);
2048 let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
2049 let styles = partial["templateStyles"]
2050 .as_array()
2051 .expect("templateStyles should be an array");
2052 let templates = partial["templates"]
2053 .as_object()
2054 .expect("templates should be an object");
2055
2056 assert!(
2057 styles.is_empty(),
2058 "Style strategy should produce empty templateStyles: {styles:?}"
2059 );
2060 assert_eq!(templates.len(), 1, "should include template metadata");
2061 assert!(
2062 templates
2063 .get("my-page")
2064 .and_then(|template| template["h"].as_str())
2065 .unwrap_or_default()
2066 .contains("<style>"),
2067 "Style strategy template should contain inline <style> tag"
2068 );
2069 }
2070
2071 #[test]
2072 fn test_render_partial_empty_styles_for_no_css_components() {
2073 let mut fragments = HashMap::new();
2074 fragments.insert(
2075 "index.html".to_string(),
2076 FragmentList {
2077 fragments: vec![WebUIFragment::component("my-page")],
2078 },
2079 );
2080 fragments.insert(
2081 "my-page".to_string(),
2082 FragmentList {
2083 fragments: vec![WebUIFragment::raw("<p>page</p>")],
2084 },
2085 );
2086
2087 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2088 let component = protocol
2089 .components
2090 .entry("my-page".to_string())
2091 .or_default();
2092 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
2093 let mut index = ProtocolIndex::new(&protocol);
2096 let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
2097 let styles = partial["templateStyles"]
2098 .as_array()
2099 .expect("templateStyles should be an array");
2100 assert!(
2101 styles.is_empty(),
2102 "templateStyles should be empty when components have no CSS"
2103 );
2104 }
2105
2106 #[test]
2107 fn test_render_partial_sends_styles_even_when_templates_filtered_by_inventory() {
2108 let mut fragments = HashMap::new();
2109 fragments.insert(
2110 "index.html".to_string(),
2111 FragmentList {
2112 fragments: vec![WebUIFragment::component("my-page")],
2113 },
2114 );
2115 fragments.insert(
2116 "my-page".to_string(),
2117 FragmentList {
2118 fragments: vec![WebUIFragment::raw("<p>page</p>")],
2119 },
2120 );
2121
2122 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2123 let component = protocol
2124 .components
2125 .entry("my-page".to_string())
2126 .or_default();
2127 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
2128 component.css = ".page{color:red}".to_string();
2129
2130 let mut index = ProtocolIndex::new(&protocol);
2131 let partial1 = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
2133 let inv = partial1["inventory"].as_str().unwrap_or_default();
2134 assert!(!inv.is_empty());
2135
2136 let partial2 = render_partial(&protocol, "index.html", "/", inv, &mut index).unwrap();
2141 let styles = partial2["templateStyles"]
2142 .as_array()
2143 .expect("templateStyles should be an array");
2144 let templates = partial2["templates"]
2145 .as_object()
2146 .expect("templates should be an object");
2147
2148 assert!(
2149 templates.is_empty(),
2150 "templates should be empty when inventory is full"
2151 );
2152 assert!(
2153 styles.is_empty(),
2154 "module styles should be empty when inventory is full — SSR already placed them"
2155 );
2156 }
2157
2158 #[test]
2159 fn test_non_route_siblings_included_in_needed_components() {
2160 let mut fragments = HashMap::new();
2165
2166 fragments.insert(
2168 "index.html".to_string(),
2169 FragmentList {
2170 fragments: vec![WebUIFragment::component("app-shell")],
2171 },
2172 );
2173
2174 fragments.insert(
2176 "app-shell".to_string(),
2177 FragmentList {
2178 fragments: vec![
2179 WebUIFragment::component("my-navbar"),
2180 WebUIFragment::route_from(WebUiFragmentRoute {
2181 path: "/about".into(),
2182 fragment_id: "page-about".into(),
2183 exact: true,
2184 keep_alive: false,
2185 ..Default::default()
2186 }),
2187 WebUIFragment::component("cart-panel"),
2188 ],
2189 },
2190 );
2191
2192 fragments.insert(
2194 "my-navbar".to_string(),
2195 FragmentList {
2196 fragments: vec![WebUIFragment::raw("<nav/>")],
2197 },
2198 );
2199 fragments.insert(
2200 "page-about".to_string(),
2201 FragmentList {
2202 fragments: vec![WebUIFragment::raw("<h1>About</h1>")],
2203 },
2204 );
2205 fragments.insert(
2206 "cart-panel".to_string(),
2207 FragmentList {
2208 fragments: vec![WebUIFragment::raw("<aside>Cart</aside>")],
2209 },
2210 );
2211
2212 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2213 for name in ["app-shell", "my-navbar", "page-about", "cart-panel"] {
2214 let comp = protocol.components.entry(name.to_string()).or_default();
2215 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
2216 comp.css = format!(".{name}{{display:block}}");
2217 }
2218
2219 let comp_index = build_component_index(&protocol);
2220 let (needed, _inv) =
2221 get_needed_components_for_request(&protocol, "index.html", "/about", "", &comp_index)
2222 .unwrap();
2223
2224 assert!(
2225 needed.contains(&"app-shell".to_string()),
2226 "app-shell should be needed: {needed:?}"
2227 );
2228 assert!(
2229 needed.contains(&"my-navbar".to_string()),
2230 "my-navbar (non-route sibling) should be needed: {needed:?}"
2231 );
2232 assert!(
2233 needed.contains(&"page-about".to_string()),
2234 "page-about (active route) should be needed: {needed:?}"
2235 );
2236 assert!(
2237 needed.contains(&"cart-panel".to_string()),
2238 "cart-panel (non-route sibling) should be needed: {needed:?}"
2239 );
2240 }
2241
2242 #[test]
2245 fn test_chain_entry_to_json_includes_allowed_query() {
2246 let entry = RouteChainEntry {
2247 component: "compose-page".into(),
2248 path: "compose".into(),
2249 params: HashMap::new(),
2250 exact: true,
2251 allowed_query: "action,to,subject".into(),
2252 keep_alive: false,
2253 cache_tags: Vec::new(),
2254 invalidates: Vec::new(),
2255 pending_component: String::new(),
2256 error_component: String::new(),
2257 };
2258 let json = entry.to_json();
2259 assert_eq!(json["allowedQuery"], "action,to,subject");
2260 }
2261
2262 #[test]
2263 fn test_chain_entry_to_json_omits_empty_allowed_query() {
2264 let entry = RouteChainEntry {
2265 component: "home-page".into(),
2266 path: "/".into(),
2267 params: HashMap::new(),
2268 exact: true,
2269 allowed_query: String::new(),
2270 keep_alive: false,
2271 cache_tags: Vec::new(),
2272 invalidates: Vec::new(),
2273 pending_component: String::new(),
2274 error_component: String::new(),
2275 };
2276 let json = entry.to_json();
2277 assert!(
2278 json.get("allowedQuery").is_none(),
2279 "empty allowed_query should not appear in JSON"
2280 );
2281 }
2282
2283 #[test]
2284 fn test_collect_route_chain_carries_allowed_query() {
2285 let mut fragments = HashMap::new();
2286 fragments.insert(
2287 "index.html".to_string(),
2288 FragmentList {
2289 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2290 path: "/".into(),
2291 fragment_id: "app-shell".into(),
2292 exact: false,
2293 children: vec![WebUiFragmentRoute {
2294 path: "compose".into(),
2295 fragment_id: "compose-page".into(),
2296 exact: true,
2297 allowed_query: "action,to".into(),
2298 keep_alive: false,
2299 ..Default::default()
2300 }],
2301 keep_alive: false,
2302 ..Default::default()
2303 })],
2304 },
2305 );
2306 fragments.insert(
2307 "app-shell".to_string(),
2308 FragmentList {
2309 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2310 },
2311 );
2312 fragments.insert(
2313 "compose-page".to_string(),
2314 FragmentList {
2315 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
2316 },
2317 );
2318 let protocol = WebUIProtocol::new(fragments);
2319
2320 let chain = collect_route_chain(
2321 &protocol,
2322 "index.html",
2323 "/compose",
2324 &mut CompiledRouteCache::new(),
2325 );
2326 assert_eq!(chain.len(), 2);
2327 assert_eq!(chain[0].component, "app-shell");
2328 assert!(chain[0].allowed_query.is_empty());
2329 assert_eq!(chain[1].component, "compose-page");
2330 assert_eq!(chain[1].allowed_query, "action,to");
2331 }
2332
2333 #[test]
2334 fn test_render_component_templates_returns_template_and_css() {
2335 let mut fragments = HashMap::new();
2336 fragments.insert(
2337 "settings-dialog".to_string(),
2338 FragmentList {
2339 fragments: vec![WebUIFragment::raw("<div class='dialog'>Settings</div>")],
2340 },
2341 );
2342 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2343 let comp = protocol
2344 .components
2345 .entry("settings-dialog".to_string())
2346 .or_default();
2347 comp.template_json = r#"{"h":"<div>Settings</div>"}"#.to_string();
2348 comp.css = ".dialog{position:fixed}".to_string();
2349
2350 let mut index = ProtocolIndex::new(&protocol);
2351 let result =
2352 render_component_templates(&protocol, &["settings-dialog"], "", &mut index).unwrap();
2353 let templates = result["templates"].as_object().expect("templates object");
2354 let styles = result["templateStyles"].as_array().expect("styles array");
2355
2356 assert_eq!(templates.len(), 1);
2357 assert_eq!(templates["settings-dialog"]["h"], "<div>Settings</div>");
2358 assert_eq!(styles.len(), 1);
2359 let style_html = styles[0].as_str().unwrap();
2360 assert!(
2361 style_html.starts_with(r#"<script type="importmap""#)
2362 && style_html.contains(r#""settings-dialog":"data:text/css,"#),
2363 "templateStyles entry should be an importmap registering settings-dialog: {style_html}"
2364 );
2365 assert!(
2368 style_html.contains(".dialog{position:fixed}"),
2369 "templateStyles entry should contain the CSS content verbatim: {style_html}"
2370 );
2371 assert!(
2372 index
2373 .template_metadata_cache
2374 .contains_key("settings-dialog"),
2375 "parsed static template metadata should be cached in ProtocolIndex"
2376 );
2377 }
2378
2379 #[test]
2380 fn test_render_component_templates_respects_inventory() {
2381 let mut fragments = HashMap::new();
2382 fragments.insert(
2383 "my-dialog".to_string(),
2384 FragmentList {
2385 fragments: vec![WebUIFragment::raw("<div>Dialog</div>")],
2386 },
2387 );
2388 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2389 let comp = protocol
2390 .components
2391 .entry("my-dialog".to_string())
2392 .or_default();
2393 comp.template_json = r#"{"h":"<div>Dialog</div>"}"#.to_string();
2394 comp.css = ".d{color:red}".to_string();
2395
2396 let mut index = ProtocolIndex::new(&protocol);
2397 let result1 =
2399 render_component_templates(&protocol, &["my-dialog"], "", &mut index).unwrap();
2400 let inv = result1["inventory"].as_str().expect("inventory string");
2401 assert_eq!(result1["templates"].as_object().unwrap().len(), 1);
2402
2403 let result2 =
2405 render_component_templates(&protocol, &["my-dialog"], inv, &mut index).unwrap();
2406 assert_eq!(result2["templates"].as_object().unwrap().len(), 0);
2407 assert_eq!(result2["templateStyles"].as_array().unwrap().len(), 0);
2408 }
2409
2410 #[test]
2411 fn test_render_component_templates_unknown_component_returns_empty() {
2412 let fragments = HashMap::new();
2413 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2414
2415 let mut index = ProtocolIndex::new(&protocol);
2416 let result =
2417 render_component_templates(&protocol, &["nonexistent-widget"], "", &mut index).unwrap();
2418 assert_eq!(result["templates"].as_object().unwrap().len(), 0);
2419 assert_eq!(result["templateStyles"].as_array().unwrap().len(), 0);
2420 }
2421
2422 #[test]
2423 fn test_render_partial_includes_sibling_boundary_templates() {
2424 let mut fragments = HashMap::new();
2425 fragments.insert(
2426 "index.html".to_string(),
2427 FragmentList {
2428 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2429 path: "/".into(),
2430 fragment_id: "app-shell".into(),
2431 exact: false,
2432 children: vec![
2433 WebUiFragmentRoute {
2434 path: "inbox".into(),
2435 fragment_id: "inbox-page".into(),
2436 exact: true,
2437 pending_component: "inbox-loading".into(),
2438 ..Default::default()
2439 },
2440 WebUiFragmentRoute {
2441 path: "settings".into(),
2442 fragment_id: "settings-page".into(),
2443 exact: true,
2444 pending_component: "settings-loading".into(),
2445 error_component: "settings-error".into(),
2446 ..Default::default()
2447 },
2448 ],
2449 ..Default::default()
2450 })],
2451 },
2452 );
2453
2454 for name in [
2455 "app-shell",
2456 "inbox-page",
2457 "inbox-loading",
2458 "settings-page",
2459 "settings-loading",
2460 "settings-error",
2461 ] {
2462 fragments.insert(
2463 name.to_string(),
2464 FragmentList {
2465 fragments: vec![WebUIFragment::raw(format!("<p>{name}</p>"))],
2466 },
2467 );
2468 }
2469
2470 let mut protocol = WebUIProtocol::new(fragments);
2471 for name in [
2472 "app-shell",
2473 "inbox-page",
2474 "inbox-loading",
2475 "settings-page",
2476 "settings-loading",
2477 "settings-error",
2478 ] {
2479 protocol
2480 .components
2481 .entry(name.to_string())
2482 .or_default()
2483 .template_json = format!(r#"{{"h":"<p>{name}</p>"}}"#);
2484 }
2485
2486 let mut index = ProtocolIndex::new(&protocol);
2487 let partial = render_partial(&protocol, "index.html", "/inbox", "", &mut index).unwrap();
2488 let templates = partial["templates"].as_object().unwrap();
2489
2490 assert!(
2491 templates.contains_key("settings-loading"),
2492 "sibling pending template should be preloaded for route transitions: {partial}"
2493 );
2494 assert!(
2495 templates.contains_key("settings-error"),
2496 "sibling error template should be preloaded for failed route transitions: {partial}"
2497 );
2498 let chain = partial["chain"].as_array().unwrap();
2499 assert_eq!(chain.len(), 2);
2500 assert_eq!(chain[0]["component"], "app-shell");
2501 assert_eq!(chain[1]["component"], "inbox-page");
2502 }
2503
2504 #[test]
2507 fn test_resolve_tag_templates_no_placeholders() {
2508 let tags = vec!["inbox".to_string(), "counts".to_string()];
2509 let params = HashMap::new();
2510 let resolved = resolve_tag_templates(&tags, ¶ms);
2511 assert_eq!(resolved, vec!["inbox", "counts"]);
2512 }
2513
2514 #[test]
2515 fn test_resolve_tag_templates_with_params() {
2516 let tags = vec!["thread:{threadId}".to_string(), "inbox".to_string()];
2517 let mut params = HashMap::new();
2518 params.insert("threadId".to_string(), "42".to_string());
2519 let resolved = resolve_tag_templates(&tags, ¶ms);
2520 assert_eq!(resolved, vec!["thread:42", "inbox"]);
2521 }
2522
2523 #[test]
2524 fn test_resolve_tag_templates_multiple_params() {
2525 let tags = vec!["folder:{folderId}:user:{userId}".to_string()];
2526 let mut params = HashMap::new();
2527 params.insert("folderId".to_string(), "drafts".to_string());
2528 params.insert("userId".to_string(), "abc".to_string());
2529 let resolved = resolve_tag_templates(&tags, ¶ms);
2530 assert_eq!(resolved, vec!["folder:drafts:user:abc"]);
2531 }
2532
2533 #[test]
2534 fn test_resolve_tag_templates_missing_param_left_unresolved() {
2535 let tags = vec!["thread:{threadId}".to_string()];
2536 let params = HashMap::new();
2537 let resolved = resolve_tag_templates(&tags, ¶ms);
2538 assert_eq!(resolved, vec!["thread:{threadId}"]);
2539 }
2540
2541 #[test]
2544 fn test_chain_entry_to_json_includes_new_fields() {
2545 let entry = RouteChainEntry {
2546 component: "mail-thread".into(),
2547 path: "email/:threadId".into(),
2548 params: {
2549 let mut p = HashMap::new();
2550 p.insert("threadId".to_string(), "42".to_string());
2551 p
2552 },
2553 exact: true,
2554 allowed_query: String::new(),
2555 keep_alive: false,
2556 cache_tags: vec!["thread:42".to_string()],
2557 invalidates: vec!["inbox".to_string(), "counts".to_string()],
2558 pending_component: "mail-skeleton".into(),
2559 error_component: "error-page".into(),
2560 };
2561 let json = entry.to_json();
2562 assert_eq!(json["pendingComponent"], "mail-skeleton");
2563 assert_eq!(json["errorComponent"], "error-page");
2564 let inv = json["invalidates"].as_array().unwrap();
2565 assert_eq!(inv.len(), 2);
2566 assert_eq!(inv[0], "inbox");
2567 assert_eq!(inv[1], "counts");
2568 }
2569
2570 #[test]
2571 fn test_chain_entry_to_json_omits_empty_new_fields() {
2572 let entry = RouteChainEntry {
2573 component: "home-page".into(),
2574 path: "/".into(),
2575 params: HashMap::new(),
2576 exact: true,
2577 allowed_query: String::new(),
2578 keep_alive: false,
2579 cache_tags: Vec::new(),
2580 invalidates: Vec::new(),
2581 pending_component: String::new(),
2582 error_component: String::new(),
2583 };
2584 let json = entry.to_json();
2585 assert!(json.get("pendingComponent").is_none());
2586 assert!(json.get("errorComponent").is_none());
2587 assert!(json.get("invalidates").is_none());
2588 }
2589
2590 #[test]
2593 fn test_render_partial_includes_resolved_cache_tags() {
2594 let mut fragments = HashMap::new();
2595 fragments.insert(
2596 "index.html".to_string(),
2597 FragmentList {
2598 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2599 path: "/".into(),
2600 fragment_id: "app-shell".into(),
2601 exact: false,
2602 children: vec![WebUiFragmentRoute {
2603 path: "email/:threadId".into(),
2604 fragment_id: "mail-thread".into(),
2605 exact: true,
2606 cache_tags: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
2607 ..Default::default()
2608 }],
2609 cache_tags: vec!["folders".to_string()],
2610 ..Default::default()
2611 })],
2612 },
2613 );
2614 fragments.insert(
2615 "app-shell".to_string(),
2616 FragmentList {
2617 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2618 },
2619 );
2620 fragments.insert(
2621 "mail-thread".to_string(),
2622 FragmentList {
2623 fragments: vec![WebUIFragment::raw("<p>Thread</p>")],
2624 },
2625 );
2626 let protocol = WebUIProtocol::new(fragments);
2627
2628 let mut index = ProtocolIndex::new(&protocol);
2629 let partial = render_partial(&protocol, "index.html", "/email/42", "", &mut index).unwrap();
2630 let tags = partial["cacheTags"].as_array().unwrap();
2631 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
2632 assert!(
2633 tag_strings.contains(&"folders"),
2634 "should contain parent tags"
2635 );
2636 assert!(
2637 tag_strings.contains(&"thread:42"),
2638 "should resolve threadId param to 42"
2639 );
2640 assert!(tag_strings.contains(&"inbox"), "should contain static tags");
2641 }
2642
2643 #[test]
2646 fn test_render_action_response_returns_resolved_invalidation_tags() {
2647 let mut fragments = HashMap::new();
2648 fragments.insert(
2649 "index.html".to_string(),
2650 FragmentList {
2651 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2652 path: "/".into(),
2653 fragment_id: "app-shell".into(),
2654 exact: false,
2655 children: vec![WebUiFragmentRoute {
2656 path: "compose".into(),
2657 fragment_id: "compose-page".into(),
2658 exact: true,
2659 invalidates: vec![
2660 "inbox".to_string(),
2661 "sent".to_string(),
2662 "counts".to_string(),
2663 ],
2664 ..Default::default()
2665 }],
2666 ..Default::default()
2667 })],
2668 },
2669 );
2670 fragments.insert(
2671 "app-shell".to_string(),
2672 FragmentList {
2673 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2674 },
2675 );
2676 fragments.insert(
2677 "compose-page".to_string(),
2678 FragmentList {
2679 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
2680 },
2681 );
2682 let protocol = WebUIProtocol::new(fragments);
2683
2684 let mut index = ProtocolIndex::new(&protocol);
2685 let result = render_action_response(
2686 &protocol,
2687 serde_json::json!({"ok": true}),
2688 "index.html",
2689 "/compose",
2690 &mut index,
2691 );
2692
2693 let tags = result["invalidateTags"].as_array().unwrap();
2694 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
2695 assert_eq!(tag_strings, vec!["inbox", "sent", "counts"]);
2696 assert_eq!(result["state"]["ok"], true);
2697 }
2698
2699 #[test]
2700 fn test_render_action_response_resolves_param_placeholders() {
2701 let mut fragments = HashMap::new();
2702 fragments.insert(
2703 "index.html".to_string(),
2704 FragmentList {
2705 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2706 path: "/".into(),
2707 fragment_id: "app-shell".into(),
2708 exact: false,
2709 children: vec![WebUiFragmentRoute {
2710 path: "email/:threadId/reply".into(),
2711 fragment_id: "reply-page".into(),
2712 exact: true,
2713 invalidates: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
2714 ..Default::default()
2715 }],
2716 ..Default::default()
2717 })],
2718 },
2719 );
2720 fragments.insert(
2721 "app-shell".to_string(),
2722 FragmentList {
2723 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2724 },
2725 );
2726 fragments.insert(
2727 "reply-page".to_string(),
2728 FragmentList {
2729 fragments: vec![WebUIFragment::raw("<p>Reply</p>")],
2730 },
2731 );
2732 let protocol = WebUIProtocol::new(fragments);
2733
2734 let mut index = ProtocolIndex::new(&protocol);
2735 let result = render_action_response(
2736 &protocol,
2737 serde_json::json!({}),
2738 "index.html",
2739 "/email/42/reply",
2740 &mut index,
2741 );
2742
2743 let tags = result["invalidateTags"].as_array().unwrap();
2744 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
2745 assert!(tag_strings.contains(&"thread:42"));
2746 assert!(tag_strings.contains(&"inbox"));
2747 }
2748}