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