1use crate::{route_matcher, route_renderer, HandlerError, StateSelection};
12use route_matcher::CompiledRouteIndex;
13use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor};
14use serde::ser::SerializeMap;
15use serde::Serialize;
16use serde_json::{value::RawValue, Map, Value};
17use std::borrow::Cow;
18use std::collections::{HashMap, HashSet};
19use std::fmt;
20use std::sync::RwLock;
21use webui_protocol::{web_ui_fragment::Fragment, WebUIFragmentRoute, WebUIProtocol};
22
23pub struct Protocol {
35 protocol: WebUIProtocol,
36 component_index: HashMap<String, u32>,
37 route_index: CompiledRouteIndex,
38 template_metadata_cache: RwLock<HashMap<String, Value>>,
39}
40
41impl Protocol {
42 pub fn from_protobuf(bytes: &[u8]) -> std::result::Result<Self, webui_protocol::ProtocolError> {
48 WebUIProtocol::from_protobuf(bytes).map(Self::new)
49 }
50
51 #[must_use]
53 pub fn new(protocol: WebUIProtocol) -> Self {
54 let component_index = build_component_index(&protocol);
55 let route_index = CompiledRouteIndex::new(&protocol);
56 Self {
57 protocol,
58 component_index,
59 route_index,
60 template_metadata_cache: RwLock::new(HashMap::new()),
61 }
62 }
63
64 pub(crate) fn protocol(&self) -> &WebUIProtocol {
65 &self.protocol
66 }
67
68 pub(crate) fn component_index(&self) -> &HashMap<String, u32> {
69 &self.component_index
70 }
71
72 pub(crate) fn route_index(&self) -> &CompiledRouteIndex {
73 &self.route_index
74 }
75
76 #[must_use]
78 pub fn tokens(&self) -> &[String] {
79 &self.protocol.tokens
80 }
81
82 pub fn render_partial(
84 &self,
85 state_json: &str,
86 entry_id: &str,
87 request_path: &str,
88 inventory_hex: &str,
89 ) -> Result<String, HandlerError> {
90 let mut index = self.request_index();
91 let (response, state_selection) = render_partial_indexed_with_state(
92 self.protocol(),
93 entry_id,
94 request_path,
95 inventory_hex,
96 &mut index,
97 )?;
98 serialize_partial_response(&response, state_json, &state_selection)
99 }
100
101 pub fn render_component_templates(
103 &self,
104 component_tags: &[&str],
105 inventory_hex: &str,
106 ) -> Result<Value, HandlerError> {
107 let mut index = self.request_index();
108 render_component_templates_indexed(
109 self.protocol(),
110 component_tags,
111 inventory_hex,
112 &mut index,
113 )
114 }
115
116 #[cfg(test)]
117 fn render_partial_metadata(
118 &self,
119 entry_id: &str,
120 request_path: &str,
121 inventory_hex: &str,
122 ) -> Result<Value, HandlerError> {
123 let mut index = self.request_index();
124 render_partial_indexed(
125 self.protocol(),
126 entry_id,
127 request_path,
128 inventory_hex,
129 &mut index,
130 )
131 }
132
133 fn request_index(&self) -> RequestProtocolIndex<'_> {
134 RequestProtocolIndex {
135 component_index: &self.component_index,
136 route_index: &self.route_index,
137 template_metadata_cache: TemplateMetadataCache::Shared(&self.template_metadata_cache),
138 }
139 }
140}
141
142#[cfg(test)]
148struct ProtocolIndex {
149 pub component_index: HashMap<String, u32>,
151 pub route_index: CompiledRouteIndex,
153 template_metadata_cache: HashMap<String, Value>,
155}
156
157struct ComponentAssets {
158 styles: Vec<Value>,
159 templates: serde_json::Map<String, Value>,
160 functions: serde_json::Map<String, Value>,
161}
162
163#[cfg(test)]
164impl ProtocolIndex {
165 #[must_use]
167 pub(crate) fn new(protocol: &WebUIProtocol) -> Self {
168 Self {
169 component_index: build_component_index(protocol),
170 route_index: CompiledRouteIndex::new(protocol),
171 template_metadata_cache: HashMap::new(),
172 }
173 }
174
175 fn request_index(&mut self) -> RequestProtocolIndex<'_> {
176 RequestProtocolIndex {
177 component_index: &self.component_index,
178 route_index: &self.route_index,
179 template_metadata_cache: TemplateMetadataCache::Exclusive(
180 &mut self.template_metadata_cache,
181 ),
182 }
183 }
184}
185
186enum TemplateMetadataCache<'a> {
187 #[cfg(test)]
188 Exclusive(&'a mut HashMap<String, Value>),
189 Shared(&'a RwLock<HashMap<String, Value>>),
190}
191
192struct RequestProtocolIndex<'a> {
193 component_index: &'a HashMap<String, u32>,
194 route_index: &'a CompiledRouteIndex,
195 template_metadata_cache: TemplateMetadataCache<'a>,
196}
197
198pub(crate) fn build_component_index(protocol: &WebUIProtocol) -> HashMap<String, u32> {
207 let mut sorted: Vec<&String> = protocol
208 .fragments
209 .keys()
210 .filter(|key| key.contains('-'))
211 .collect();
212 sorted.sort_unstable();
213 let mut index = HashMap::with_capacity(sorted.len());
214 for (i, name) in sorted.into_iter().enumerate() {
215 #[allow(clippy::cast_possible_truncation)]
217 let idx = i as u32;
218 index.insert(name.clone(), idx);
219 }
220 index
221}
222
223fn has_component(inventory: &[u8], index: u32) -> bool {
225 let byte_idx = (index / 8) as usize;
226 let bit_idx = index % 8;
227 byte_idx < inventory.len() && inventory[byte_idx] & (1 << bit_idx) != 0
228}
229
230fn set_component(inventory: &mut Vec<u8>, index: u32) {
232 let byte_idx = (index / 8) as usize;
233 let bit_idx = index % 8;
234 if byte_idx >= inventory.len() {
235 inventory.resize(byte_idx + 1, 0);
236 }
237 inventory[byte_idx] |= 1 << bit_idx;
238}
239
240pub fn parse_inventory(hex: &str) -> Result<Vec<u8>, HandlerError> {
244 if hex.is_empty() {
245 return Ok(Vec::new());
246 }
247 if !hex.len().is_multiple_of(2) {
248 return Err(HandlerError::Protocol(
249 webui_protocol::ProtocolError::Validation(format!(
250 "inventory hex has odd length: {}",
251 hex.len()
252 )),
253 ));
254 }
255 let mut bytes = Vec::with_capacity(hex.len() / 2);
256 let mut chars = hex.bytes();
257 while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
258 let h = route_matcher::hex_val(hi).ok_or_else(|| {
259 HandlerError::Protocol(webui_protocol::ProtocolError::Validation(format!(
260 "invalid hex digit in inventory: {:#04x}",
261 hi
262 )))
263 })?;
264 let l = route_matcher::hex_val(lo).ok_or_else(|| {
265 HandlerError::Protocol(webui_protocol::ProtocolError::Validation(format!(
266 "invalid hex digit in inventory: {:#04x}",
267 lo
268 )))
269 })?;
270 bytes.push((h << 4) | l);
271 }
272 Ok(bytes)
273}
274
275pub fn encode_inventory(inv: &[u8]) -> String {
277 const HEX: &[u8; 16] = b"0123456789abcdef";
278 let mut encoded = String::with_capacity(inv.len() * 2);
279 for byte in inv {
280 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
281 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
282 }
283 encoded
284}
285
286pub(crate) fn encode_component_inventory(
288 component_names: &HashSet<String>,
289 index: &HashMap<String, u32>,
290) -> String {
291 let mut inventory = Vec::new();
292 for name in component_names {
293 if let Some(&idx) = index.get(name.as_str()) {
294 set_component(&mut inventory, idx);
295 }
296 }
297 encode_inventory(&inventory)
298}
299
300pub fn get_needed_components(
305 protocol: &WebUIProtocol,
306 entry_id: &str,
307 inventory_hex: &str,
308 component_index: &HashMap<String, u32>,
309) -> Result<(Vec<String>, String), HandlerError> {
310 let route_index = CompiledRouteIndex::new(protocol);
311 let component_names =
312 collect_inventoryable_components(protocol, entry_id, None, true, &route_index);
313 filter_needed_components(&component_names, inventory_hex, component_index)
314}
315
316pub(crate) fn get_needed_components_for_request(
321 protocol: &WebUIProtocol,
322 entry_id: &str,
323 request_path: &str,
324 inventory_hex: &str,
325 indexes: (&HashMap<String, u32>, &CompiledRouteIndex),
326) -> Result<(Vec<String>, String), HandlerError> {
327 let (component_index, route_index) = indexes;
328 let component_names = collect_inventoryable_components(
329 protocol,
330 entry_id,
331 Some(request_path),
332 false,
333 route_index,
334 );
335 filter_needed_components(&component_names, inventory_hex, component_index)
336}
337
338fn serialize_partial_response(
339 response: &Value,
340 state_json: &str,
341 state_selection: &StateSelection<'_>,
342) -> Result<String, HandlerError> {
343 let response = response
344 .as_object()
345 .ok_or_else(partial_response_not_object)?;
346 let state = select_raw_state(state_json, state_selection)?;
347 serde_json::to_string(&PartialResponseWithState { response, state })
348 .map_err(|error| partial_serialize_error(&error.to_string()))
349}
350
351fn validate_json(json: &str) -> Result<(), HandlerError> {
352 validate_json_inner(json).map_err(|error| invalid_state_json(&error.to_string()))
353}
354
355fn validate_json_inner(json: &str) -> serde_json::Result<()> {
356 let mut deserializer = serde_json::Deserializer::from_str(json);
357 ValidJson
358 .deserialize(&mut deserializer)
359 .and_then(|_| deserializer.end())
360}
361
362struct ValidJson;
363
364impl<'de> DeserializeSeed<'de> for ValidJson {
365 type Value = ();
366
367 fn deserialize<D>(self, deserializer: D) -> std::result::Result<(), D::Error>
368 where
369 D: serde::Deserializer<'de>,
370 {
371 deserializer.deserialize_any(ValidJsonVisitor)
372 }
373}
374
375struct ValidJsonVisitor;
376
377impl<'de> Visitor<'de> for ValidJsonVisitor {
378 type Value = ();
379
380 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
381 formatter.write_str("valid JSON")
382 }
383
384 fn visit_bool<E>(self, _value: bool) -> std::result::Result<(), E> {
385 Ok(())
386 }
387
388 fn visit_i64<E>(self, _value: i64) -> std::result::Result<(), E> {
389 Ok(())
390 }
391
392 fn visit_u64<E>(self, _value: u64) -> std::result::Result<(), E> {
393 Ok(())
394 }
395
396 fn visit_f64<E>(self, value: f64) -> std::result::Result<(), E>
397 where
398 E: serde::de::Error,
399 {
400 if value.is_finite() {
401 Ok(())
402 } else {
403 Err(E::custom("number out of range"))
404 }
405 }
406
407 fn visit_str<E>(self, _value: &str) -> std::result::Result<(), E> {
408 Ok(())
409 }
410
411 fn visit_borrowed_str<E>(self, _value: &'de str) -> std::result::Result<(), E> {
412 Ok(())
413 }
414
415 fn visit_string<E>(self, _value: String) -> std::result::Result<(), E> {
416 Ok(())
417 }
418
419 fn visit_unit<E>(self) -> std::result::Result<(), E> {
420 Ok(())
421 }
422
423 fn visit_none<E>(self) -> std::result::Result<(), E> {
424 Ok(())
425 }
426
427 fn visit_some<D>(self, deserializer: D) -> std::result::Result<(), D::Error>
428 where
429 D: serde::Deserializer<'de>,
430 {
431 ValidJson.deserialize(deserializer)
432 }
433
434 fn visit_seq<A>(self, mut sequence: A) -> std::result::Result<(), A::Error>
435 where
436 A: SeqAccess<'de>,
437 {
438 while sequence.next_element_seed(ValidJson)?.is_some() {}
439 Ok(())
440 }
441
442 fn visit_map<A>(self, mut map: A) -> std::result::Result<(), A::Error>
443 where
444 A: MapAccess<'de>,
445 {
446 while map.next_key::<IgnoredAny>()?.is_some() {
447 map.next_value_seed(ValidJson)?;
448 }
449 Ok(())
450 }
451}
452
453struct PartialResponseWithState<'a, 'state> {
454 response: &'a Map<String, Value>,
455 state: SelectedRawState<'state>,
456}
457
458impl Serialize for PartialResponseWithState<'_, '_> {
459 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
460 where
461 S: serde::Serializer,
462 {
463 let mut map = serializer.serialize_map(Some(self.response.len().saturating_add(1)))?;
464 for (key, value) in self.response {
465 map.serialize_entry(key, value)?;
466 }
467 map.serialize_entry("state", &self.state)?;
468 map.end()
469 }
470}
471
472struct ProjectedRawState<'de> {
473 entries: Vec<(Cow<'de, str>, &'de RawValue)>,
474}
475
476enum SelectedRawState<'de> {
477 Full(&'de RawValue),
478 Keys(ProjectedRawState<'de>),
479}
480
481impl Serialize for SelectedRawState<'_> {
482 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
483 where
484 S: serde::Serializer,
485 {
486 match self {
487 Self::Full(state) => state.serialize(serializer),
488 Self::Keys(state) => state.serialize(serializer),
489 }
490 }
491}
492
493impl Serialize for ProjectedRawState<'_> {
494 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
495 where
496 S: serde::Serializer,
497 {
498 let mut map = serializer.serialize_map(Some(self.entries.len()))?;
499 for (key, value) in &self.entries {
500 map.serialize_entry(key.as_ref(), value)?;
501 }
502 map.end()
503 }
504}
505
506fn project_raw_state<'de>(
507 state_json: &'de str,
508 state_keys: &[&str],
509) -> Result<ProjectedRawState<'de>, HandlerError> {
510 let is_object = state_json
511 .as_bytes()
512 .iter()
513 .find(|byte| !byte.is_ascii_whitespace())
514 .is_some_and(|byte| *byte == b'{');
515 if !is_object {
516 validate_json(state_json)?;
517 return Ok(ProjectedRawState {
518 entries: Vec::new(),
519 });
520 }
521
522 let mut deserializer = serde_json::Deserializer::from_str(state_json);
523 let mut state = ProjectedRawStateSeed { state_keys }
524 .deserialize(&mut deserializer)
525 .and_then(|state| {
526 deserializer.end()?;
527 Ok(state)
528 })
529 .map_err(|error| invalid_state_json(&error.to_string()))?;
530 state.entries.sort_unstable_by(|left, right| {
531 left.0.as_ref().as_bytes().cmp(right.0.as_ref().as_bytes())
532 });
533 Ok(state)
534}
535
536fn select_raw_state<'de>(
537 state_json: &'de str,
538 selection: &StateSelection<'_>,
539) -> Result<SelectedRawState<'de>, HandlerError> {
540 match selection {
541 StateSelection::Full => serde_json::from_str::<&RawValue>(state_json)
542 .map(SelectedRawState::Full)
543 .map_err(|error| invalid_state_json(&error.to_string())),
544 StateSelection::Keys(keys) => {
545 project_raw_state(state_json, keys).map(SelectedRawState::Keys)
546 }
547 }
548}
549
550struct ProjectedRawStateSeed<'a> {
551 state_keys: &'a [&'a str],
552}
553
554impl<'de> DeserializeSeed<'de> for ProjectedRawStateSeed<'_> {
555 type Value = ProjectedRawState<'de>;
556
557 fn deserialize<D>(self, deserializer: D) -> std::result::Result<Self::Value, D::Error>
558 where
559 D: serde::Deserializer<'de>,
560 {
561 deserializer.deserialize_map(ProjectedRawStateVisitor {
562 state_keys: self.state_keys,
563 })
564 }
565}
566
567struct ProjectedRawStateVisitor<'a> {
568 state_keys: &'a [&'a str],
569}
570
571impl<'de> Visitor<'de> for ProjectedRawStateVisitor<'_> {
572 type Value = ProjectedRawState<'de>;
573
574 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
575 formatter.write_str("a JSON object")
576 }
577
578 fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
579 where
580 A: MapAccess<'de>,
581 {
582 let mut entries: Vec<(Cow<'de, str>, &'de RawValue)> =
583 Vec::with_capacity(self.state_keys.len());
584 while let Some(key) = map.next_key_seed(BorrowedString)? {
585 if self.state_keys.binary_search(&key.as_ref()).is_err() {
586 map.next_value_seed(ValidJson)?;
587 continue;
588 }
589
590 let value: &'de RawValue = map.next_value()?;
591 validate_json_inner(value.get()).map_err(serde::de::Error::custom)?;
592 if let Some(entry) = entries
593 .iter_mut()
594 .find(|(existing, _)| existing.as_ref() == key.as_ref())
595 {
596 *entry = (key, value);
597 } else {
598 entries.push((key, value));
599 }
600 }
601 Ok(ProjectedRawState { entries })
602 }
603}
604
605struct BorrowedString;
606
607impl<'de> DeserializeSeed<'de> for BorrowedString {
608 type Value = Cow<'de, str>;
609
610 fn deserialize<D>(self, deserializer: D) -> std::result::Result<Self::Value, D::Error>
611 where
612 D: serde::Deserializer<'de>,
613 {
614 deserializer.deserialize_str(BorrowedStringVisitor)
615 }
616}
617
618struct BorrowedStringVisitor;
619
620impl<'de> Visitor<'de> for BorrowedStringVisitor {
621 type Value = Cow<'de, str>;
622
623 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
624 formatter.write_str("a string")
625 }
626
627 fn visit_borrowed_str<E>(self, value: &'de str) -> std::result::Result<Self::Value, E> {
628 Ok(Cow::Borrowed(value))
629 }
630
631 fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E> {
632 Ok(Cow::Owned(value.to_owned()))
633 }
634
635 fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E> {
636 Ok(Cow::Owned(value))
637 }
638}
639
640#[cold]
641#[inline(never)]
642fn invalid_state_json(message: &str) -> HandlerError {
643 HandlerError::Rendering(format!("invalid state JSON: {message}"))
644}
645
646#[cold]
647#[inline(never)]
648fn partial_serialize_error(message: &str) -> HandlerError {
649 HandlerError::Rendering(format!("failed to serialize partial response: {message}"))
650}
651
652#[cold]
653#[inline(never)]
654fn partial_response_not_object() -> HandlerError {
655 HandlerError::Invariant("partial response must serialize as a JSON object".to_string())
656}
657
658pub(crate) fn collect_reachable_components_for_request(
660 protocol: &WebUIProtocol,
661 entry_id: &str,
662 request_path: &str,
663 route_index: &CompiledRouteIndex,
664) -> HashSet<String> {
665 collect_inventoryable_components(protocol, entry_id, Some(request_path), false, route_index)
668 .into_iter()
669 .collect()
670}
671
672pub fn filter_needed_components(
682 component_names: &[String],
683 inventory_hex: &str,
684 index: &HashMap<String, u32>,
685) -> Result<(Vec<String>, String), HandlerError> {
686 let client_inv = parse_inventory(inventory_hex)?;
687 let mut updated_inv = client_inv.clone();
688
689 let mut needed = Vec::with_capacity(component_names.len());
690 for name in component_names {
691 if let Some(&idx) = index.get(name.as_str()) {
692 if !has_component(&client_inv, idx) {
693 needed.push(name.clone());
694 }
695 set_component(&mut updated_inv, idx);
696 } else {
697 needed.push(name.clone());
701 }
702 }
703
704 Ok((needed, encode_inventory(&updated_inv)))
705}
706
707fn has_template_payload(component: &webui_protocol::ComponentData) -> bool {
708 !component.template_json.is_empty() || !component.template.is_empty()
709}
710
711#[derive(Debug)]
712struct QueuedFragment {
713 id: String,
714 inventoryable: bool,
715 route_base: String,
717}
718
719struct ChildWalkCtx<'a> {
722 request_path: &'a str,
723 protocol: &'a WebUIProtocol,
724 route_index: &'a CompiledRouteIndex,
725}
726
727fn collect_inventoryable_components(
738 protocol: &WebUIProtocol,
739 entry_id: &str,
740 request_path: Option<&str>,
741 root_inventoryable: bool,
742 route_index: &CompiledRouteIndex,
743) -> Vec<String> {
744 let mut visited_fragments = HashSet::new();
745 let mut seen_components = HashSet::new();
750 let mut component_ids: Vec<String> = Vec::new();
751 let mut stack = vec![QueuedFragment {
752 id: entry_id.to_string(),
753 inventoryable: root_inventoryable,
754 route_base: "/".to_string(),
755 }];
756
757 while let Some(queued) = stack.pop() {
758 if queued.id.is_empty() {
759 continue;
760 }
761
762 if queued.inventoryable && seen_components.insert(queued.id.clone()) {
763 component_ids.push(queued.id.clone());
764 }
765
766 if !visited_fragments.insert(queued.id.clone()) {
767 continue;
768 }
769
770 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
771 continue;
772 };
773
774 let matched_route = request_path.and_then(|path| {
775 route_renderer::find_best_route_match(
776 &frag_list.fragments,
777 path,
778 &queued.route_base,
779 route_index,
780 )
781 });
782
783 for frag in frag_list.fragments.iter().rev() {
786 match frag.fragment.as_ref() {
787 Some(Fragment::Component(component)) => {
788 stack.push(QueuedFragment {
789 id: component.fragment_id.clone(),
790 inventoryable: true,
791 route_base: queued.route_base.clone(),
792 });
793 }
794 Some(Fragment::ForLoop(for_loop)) => {
795 stack.push(QueuedFragment {
796 id: for_loop.fragment_id.clone(),
797 inventoryable: false,
798 route_base: queued.route_base.clone(),
799 });
800 }
801 Some(Fragment::IfCond(if_cond)) => {
802 stack.push(QueuedFragment {
803 id: if_cond.fragment_id.clone(),
804 inventoryable: false,
805 route_base: queued.route_base.clone(),
806 });
807 }
808 Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
809 stack.push(QueuedFragment {
810 id: attr.template.clone(),
811 inventoryable: false,
812 route_base: queued.route_base.clone(),
813 });
814 }
815 Some(Fragment::Route(route_frag)) => {
816 let is_selected = matched_route
817 .as_ref()
818 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
819 if is_selected && !route_frag.fragment_id.is_empty() {
820 let child_route_base = if let Some((_, ref rm)) = matched_route {
822 if let Some(path) = request_path {
823 route_matcher::compute_route_base(path, rm.consumed_segments)
824 } else {
825 queued.route_base.clone()
826 }
827 } else {
828 queued.route_base.clone()
829 };
830
831 stack.push(QueuedFragment {
832 id: route_frag.fragment_id.clone(),
833 inventoryable: protocol
834 .components
835 .get(&route_frag.fragment_id)
836 .is_some_and(has_template_payload),
837 route_base: child_route_base.clone(),
838 });
839
840 collect_route_boundary_components(
848 std::slice::from_ref(route_frag),
849 &child_route_base,
850 protocol,
851 &mut stack,
852 );
853
854 if !route_frag.children.is_empty() {
858 if let Some(path) = request_path {
859 walk_route_children(
860 &route_frag.children,
861 &child_route_base,
862 &mut stack,
863 &mut ChildWalkCtx {
864 request_path: path,
865 protocol,
866 route_index,
867 },
868 );
869 }
870 }
871 }
872 }
873 _ => {}
874 }
875 }
876 }
877
878 component_ids
879}
880
881fn select_best_child_route(
886 children: &[WebUIFragmentRoute],
887 request_path: &str,
888 route_base: &str,
889 route_index: &CompiledRouteIndex,
890) -> Option<(usize, route_matcher::RouteMatch)> {
891 let request_segments = route_matcher::split_request_path(request_path);
892 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
893 for (idx, child) in children.iter().enumerate() {
894 if let Some(m) = route_matcher::match_route_indexed_with_segments(
895 route_index,
896 &child.path,
897 route_base,
898 &request_segments,
899 child.exact,
900 ) {
901 let is_better = best
902 .as_ref()
903 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
904 if is_better {
905 best = Some((idx, m));
906 }
907 }
908 }
909 best
910}
911
912fn walk_route_children(
915 children: &[WebUIFragmentRoute],
916 route_base: &str,
917 stack: &mut Vec<QueuedFragment>,
918 ctx: &mut ChildWalkCtx<'_>,
919) {
920 let mut current = children;
921 let mut base = route_base.to_string();
922
923 loop {
924 let Some((idx, ref rm)) =
925 select_best_child_route(current, ctx.request_path, &base, ctx.route_index)
926 else {
927 break;
928 };
929 let matched = ¤t[idx];
930 if matched.fragment_id.is_empty() {
931 break;
932 }
933
934 let child_base = route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
935
936 stack.push(QueuedFragment {
937 id: matched.fragment_id.clone(),
938 inventoryable: ctx
939 .protocol
940 .components
941 .get(&matched.fragment_id)
942 .is_some_and(has_template_payload),
943 route_base: child_base.clone(),
944 });
945
946 if !matched.pending_component.is_empty() {
947 stack.push(QueuedFragment {
948 id: matched.pending_component.clone(),
949 inventoryable: ctx
950 .protocol
951 .components
952 .get(&matched.pending_component)
953 .is_some_and(has_template_payload),
954 route_base: child_base.clone(),
955 });
956 }
957 if !matched.error_component.is_empty() {
958 stack.push(QueuedFragment {
959 id: matched.error_component.clone(),
960 inventoryable: ctx
961 .protocol
962 .components
963 .get(&matched.error_component)
964 .is_some_and(has_template_payload),
965 route_base: child_base.clone(),
966 });
967 }
968
969 if matched.children.is_empty() {
970 break;
971 }
972 current = &matched.children;
973 base = child_base;
974 }
975}
976
977fn collect_route_boundary_components(
986 routes: &[WebUIFragmentRoute],
987 route_base: &str,
988 protocol: &WebUIProtocol,
989 stack: &mut Vec<QueuedFragment>,
990) {
991 let mut remaining: Vec<&WebUIFragmentRoute> = routes.iter().collect();
992 while let Some(route) = remaining.pop() {
993 for component in [&route.pending_component, &route.error_component] {
994 if component.is_empty() {
995 continue;
996 }
997 stack.push(QueuedFragment {
998 id: component.clone(),
999 inventoryable: protocol
1000 .components
1001 .get(component)
1002 .is_some_and(has_template_payload),
1003 route_base: route_base.to_string(),
1004 });
1005 }
1006 remaining.extend(route.children.iter());
1007 }
1008}
1009
1010pub fn collect_nested_route_params(
1015 protocol: &Protocol,
1016 entry_id: &str,
1017 request_path: &str,
1018) -> HashMap<String, String> {
1019 let route_index = protocol.route_index();
1020 let protocol = protocol.protocol();
1021 let mut all_params = HashMap::new();
1022 let mut visited_fragments = HashSet::new();
1023 let mut stack = vec![QueuedFragment {
1024 id: entry_id.to_string(),
1025 inventoryable: false,
1026 route_base: "/".to_string(),
1027 }];
1028
1029 while let Some(queued) = stack.pop() {
1030 if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
1031 continue;
1032 }
1033
1034 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1035 continue;
1036 };
1037
1038 let matched_route = route_renderer::find_best_route_match(
1039 &frag_list.fragments,
1040 request_path,
1041 &queued.route_base,
1042 route_index,
1043 );
1044
1045 for frag in &frag_list.fragments {
1046 match frag.fragment.as_ref() {
1047 Some(Fragment::Component(component)) => {
1048 stack.push(QueuedFragment {
1049 id: component.fragment_id.clone(),
1050 inventoryable: false,
1051 route_base: queued.route_base.clone(),
1052 });
1053 }
1054 Some(Fragment::Route(route_frag)) => {
1055 let is_selected = matched_route
1056 .as_ref()
1057 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1058 if is_selected && !route_frag.fragment_id.is_empty() {
1059 if let Some((_, ref rm)) = matched_route {
1060 all_params.extend(rm.params.clone());
1062
1063 let child_route_base = route_matcher::compute_route_base(
1064 request_path,
1065 rm.consumed_segments,
1066 );
1067
1068 stack.push(QueuedFragment {
1069 id: route_frag.fragment_id.clone(),
1070 inventoryable: false,
1071 route_base: child_route_base.clone(),
1072 });
1073
1074 collect_params_from_children(
1076 &route_frag.children,
1077 request_path,
1078 &child_route_base,
1079 &mut all_params,
1080 route_index,
1081 );
1082 }
1083 }
1084 }
1085 _ => {}
1086 }
1087 }
1088 }
1089
1090 all_params
1091}
1092
1093fn collect_params_from_children(
1095 children: &[WebUIFragmentRoute],
1096 request_path: &str,
1097 route_base: &str,
1098 all_params: &mut HashMap<String, String>,
1099 route_index: &CompiledRouteIndex,
1100) {
1101 let mut current = children;
1102 let mut base = route_base.to_string();
1103
1104 loop {
1105 let Some((idx, rm)) = select_best_child_route(current, request_path, &base, route_index)
1106 else {
1107 break;
1108 };
1109 all_params.extend(rm.params);
1110 let matched = ¤t[idx];
1111 if matched.children.is_empty() {
1112 break;
1113 }
1114 let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
1115 current = &matched.children;
1116 base = child_base;
1117 }
1118}
1119
1120fn resolve_tag_templates(templates: &[String], params: &HashMap<String, String>) -> Vec<String> {
1128 let mut resolved = Vec::with_capacity(templates.len());
1129 for template in templates {
1130 if !template.contains('{') {
1131 resolved.push(template.clone());
1132 continue;
1133 }
1134 let mut result = String::with_capacity(template.len());
1135 let mut rest = template.as_str();
1136 while let Some(open) = rest.find('{') {
1137 result.push_str(&rest[..open]);
1138 rest = &rest[open + 1..];
1139 if let Some(close) = rest.find('}') {
1140 let name = &rest[..close];
1141 if let Some(value) = params.get(name) {
1142 result.push_str(value);
1143 } else {
1144 result.push('{');
1145 result.push_str(name);
1146 result.push('}');
1147 }
1148 rest = &rest[close + 1..];
1149 } else {
1150 result.push('{');
1151 }
1152 }
1153 result.push_str(rest);
1154 resolved.push(result);
1155 }
1156 resolved
1157}
1158
1159fn collect_inventory_and_chain(
1163 protocol: &WebUIProtocol,
1164 entry_id: &str,
1165 request_path: &str,
1166 index: &mut RequestProtocolIndex<'_>,
1167) -> (Vec<String>, Vec<RouteChainEntry>) {
1168 let mut visited_fragments = HashSet::new();
1169 let mut seen_components = HashSet::new();
1174 let mut component_ids: Vec<String> = Vec::new();
1175 let mut chain = Vec::new();
1176 let mut stack = vec![QueuedFragment {
1177 id: entry_id.to_string(),
1178 inventoryable: false,
1179 route_base: "/".to_string(),
1180 }];
1181
1182 while let Some(queued) = stack.pop() {
1183 if queued.id.is_empty() {
1184 continue;
1185 }
1186
1187 if queued.inventoryable && seen_components.insert(queued.id.clone()) {
1188 component_ids.push(queued.id.clone());
1189 }
1190
1191 if !visited_fragments.insert(queued.id.clone()) {
1192 continue;
1193 }
1194
1195 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1196 continue;
1197 };
1198
1199 let matched_route = route_renderer::find_best_route_match(
1200 &frag_list.fragments,
1201 request_path,
1202 &queued.route_base,
1203 index.route_index,
1204 );
1205
1206 for frag in &frag_list.fragments {
1207 match frag.fragment.as_ref() {
1208 Some(Fragment::Component(component)) => {
1209 stack.push(QueuedFragment {
1211 id: component.fragment_id.clone(),
1212 inventoryable: true,
1213 route_base: queued.route_base.clone(),
1214 });
1215 }
1216 Some(Fragment::ForLoop(for_loop)) => {
1217 stack.push(QueuedFragment {
1219 id: for_loop.fragment_id.clone(),
1220 inventoryable: false,
1221 route_base: queued.route_base.clone(),
1222 });
1223 }
1224 Some(Fragment::IfCond(if_cond)) => {
1225 stack.push(QueuedFragment {
1226 id: if_cond.fragment_id.clone(),
1227 inventoryable: false,
1228 route_base: queued.route_base.clone(),
1229 });
1230 }
1231 Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
1232 stack.push(QueuedFragment {
1233 id: attr.template.clone(),
1234 inventoryable: false,
1235 route_base: queued.route_base.clone(),
1236 });
1237 }
1238 Some(Fragment::Route(route_frag)) => {
1239 let is_selected = matched_route
1240 .as_ref()
1241 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1242 if is_selected && !route_frag.fragment_id.is_empty() {
1243 if let Some((_, ref rm)) = matched_route {
1244 chain.push(RouteChainEntry {
1246 component: route_frag.fragment_id.clone(),
1247 path: route_frag.path.clone(),
1248 params: rm.params.clone(),
1249 exact: route_frag.exact,
1250 allowed_query: route_frag.allowed_query.clone(),
1251 keep_alive: route_frag.keep_alive,
1252 cache_tags: route_frag.cache_tags.clone(),
1253 invalidates: route_frag.invalidates.clone(),
1254 pending_component: route_frag.pending_component.clone(),
1255 error_component: route_frag.error_component.clone(),
1256 });
1257
1258 let child_route_base = route_matcher::compute_route_base(
1259 request_path,
1260 rm.consumed_segments,
1261 );
1262
1263 let is_inventoryable = protocol
1265 .components
1266 .get(&route_frag.fragment_id)
1267 .is_some_and(has_template_payload);
1268 stack.push(QueuedFragment {
1269 id: route_frag.fragment_id.clone(),
1270 inventoryable: is_inventoryable,
1271 route_base: child_route_base.clone(),
1272 });
1273
1274 collect_route_boundary_components(
1275 std::slice::from_ref(route_frag),
1276 &child_route_base,
1277 protocol,
1278 &mut stack,
1279 );
1280
1281 if !route_frag.children.is_empty() {
1283 walk_children_for_inventory_and_chain(
1284 &route_frag.children,
1285 &child_route_base,
1286 &mut stack,
1287 &mut chain,
1288 &mut ChildWalkCtx {
1289 request_path,
1290 protocol,
1291 route_index: index.route_index,
1292 },
1293 );
1294 }
1295 }
1296 }
1297 }
1298 _ => {}
1299 }
1300 }
1301 }
1302
1303 (component_ids, chain)
1304}
1305
1306fn walk_children_for_inventory_and_chain(
1308 children: &[WebUIFragmentRoute],
1309 route_base: &str,
1310 stack: &mut Vec<QueuedFragment>,
1311 chain: &mut Vec<RouteChainEntry>,
1312 ctx: &mut ChildWalkCtx<'_>,
1313) {
1314 let mut current = children;
1315 let mut base = route_base.to_string();
1316
1317 loop {
1318 let Some((idx, ref rm)) =
1319 select_best_child_route(current, ctx.request_path, &base, ctx.route_index)
1320 else {
1321 break;
1322 };
1323 let matched = ¤t[idx];
1324 if matched.fragment_id.is_empty() {
1325 break;
1326 }
1327
1328 let child_base = route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
1329
1330 chain.push(RouteChainEntry {
1331 component: matched.fragment_id.clone(),
1332 path: matched.path.clone(),
1333 params: rm.params.clone(),
1334 exact: matched.exact,
1335 allowed_query: matched.allowed_query.clone(),
1336 keep_alive: matched.keep_alive,
1337 cache_tags: matched.cache_tags.clone(),
1338 invalidates: matched.invalidates.clone(),
1339 pending_component: matched.pending_component.clone(),
1340 error_component: matched.error_component.clone(),
1341 });
1342
1343 stack.push(QueuedFragment {
1344 id: matched.fragment_id.clone(),
1345 inventoryable: ctx
1346 .protocol
1347 .components
1348 .get(&matched.fragment_id)
1349 .is_some_and(has_template_payload),
1350 route_base: child_base.clone(),
1351 });
1352
1353 if matched.children.is_empty() {
1354 break;
1355 }
1356 current = &matched.children;
1357 base = child_base;
1358 }
1359}
1360
1361#[cfg(test)]
1376fn render_partial_metadata(
1377 protocol: &WebUIProtocol,
1378 entry_id: &str,
1379 request_path: &str,
1380 inventory_hex: &str,
1381 index: &mut ProtocolIndex,
1382) -> Result<Value, HandlerError> {
1383 let mut request_index = index.request_index();
1384 render_partial_indexed(
1385 protocol,
1386 entry_id,
1387 request_path,
1388 inventory_hex,
1389 &mut request_index,
1390 )
1391}
1392
1393#[cfg(test)]
1400fn render_partial(
1401 protocol: &WebUIProtocol,
1402 state: Value,
1403 entry_id: &str,
1404 request_path: &str,
1405 inventory_hex: &str,
1406) -> Result<Value, HandlerError> {
1407 let mut index = ProtocolIndex::new(protocol);
1408 let mut request_index = index.request_index();
1409 let (mut response, state_selection) = render_partial_indexed_with_state(
1410 protocol,
1411 entry_id,
1412 request_path,
1413 inventory_hex,
1414 &mut request_index,
1415 )?;
1416 let response = response
1417 .as_object_mut()
1418 .ok_or_else(partial_response_not_object)?;
1419 response.insert("state".into(), select_owned_state(state, &state_selection));
1420 Ok(Value::Object(std::mem::take(response)))
1421}
1422
1423#[cfg(test)]
1424fn render_partial_indexed(
1425 protocol: &WebUIProtocol,
1426 entry_id: &str,
1427 request_path: &str,
1428 inventory_hex: &str,
1429 index: &mut RequestProtocolIndex<'_>,
1430) -> Result<Value, HandlerError> {
1431 render_partial_indexed_with_state(protocol, entry_id, request_path, inventory_hex, index)
1432 .map(|(response, _)| response)
1433}
1434
1435fn render_partial_indexed_with_state<'a>(
1436 protocol: &'a WebUIProtocol,
1437 entry_id: &str,
1438 request_path: &str,
1439 inventory_hex: &str,
1440 index: &mut RequestProtocolIndex<'_>,
1441) -> Result<(Value, StateSelection<'a>), HandlerError> {
1442 let (component_ids, mut chain) =
1444 collect_inventory_and_chain(protocol, entry_id, request_path, index);
1445 let state_selection =
1446 crate::collect_navigation_state(protocol, component_ids.iter().map(String::as_str));
1447
1448 let (needed_names, updated_inv) =
1449 filter_needed_components(&component_ids, inventory_hex, index.component_index)?;
1450
1451 let mut accumulated_params: HashMap<String, String> = HashMap::new();
1453 let mut all_resolved_tags: Vec<String> = Vec::new();
1454
1455 for entry in &mut chain {
1456 for (k, v) in &entry.params {
1457 accumulated_params.insert(k.clone(), v.clone());
1458 }
1459 entry.cache_tags = resolve_tag_templates(&entry.cache_tags, &accumulated_params);
1460 entry.invalidates = resolve_tag_templates(&entry.invalidates, &accumulated_params);
1461 all_resolved_tags.extend(entry.cache_tags.iter().cloned());
1462 }
1463
1464 let tag_refs: Vec<&str> = needed_names.iter().map(|s| s.as_str()).collect();
1465 let assets = collect_component_assets(protocol, &tag_refs, index)?;
1466
1467 let chain_array = Value::Array(chain.iter().map(RouteChainEntry::to_json).collect());
1468
1469 let mut result = serde_json::Map::with_capacity(7);
1470 result.insert("templateStyles".into(), Value::Array(assets.styles));
1471 result.insert("templates".into(), Value::Object(assets.templates));
1472 result.insert("templateFunctions".into(), Value::Object(assets.functions));
1473 result.insert("inventory".into(), Value::String(updated_inv));
1474 result.insert("path".into(), Value::String(request_path.to_string()));
1475 result.insert("chain".into(), chain_array);
1476 if !all_resolved_tags.is_empty() {
1477 let mut seen = HashSet::new();
1478 let deduped: Vec<Value> = all_resolved_tags
1479 .into_iter()
1480 .filter(|t| seen.insert(t.clone()))
1481 .map(Value::String)
1482 .collect();
1483 result.insert("cacheTags".into(), Value::Array(deduped));
1484 }
1485 Ok((Value::Object(result), state_selection))
1486}
1487
1488#[cfg(test)]
1489fn select_owned_state(state: Value, selection: &StateSelection<'_>) -> Value {
1490 let StateSelection::Keys(state_keys) = selection else {
1491 return state;
1492 };
1493 let Value::Object(mut source) = state else {
1494 return Value::Object(Map::new());
1495 };
1496 let mut projected = Map::with_capacity(state_keys.len().min(source.len()));
1497 for &key in state_keys {
1498 if let Some(value) = source.remove(key) {
1499 projected.insert(key.to_owned(), value);
1500 }
1501 }
1502 Value::Object(projected)
1503}
1504
1505#[cfg(test)]
1516fn render_action_response(
1517 protocol: &WebUIProtocol,
1518 state: Value,
1519 entry_id: &str,
1520 request_path: &str,
1521 index: &mut ProtocolIndex,
1522) -> Value {
1523 let mut request_index = index.request_index();
1524 render_action_response_indexed(protocol, state, entry_id, request_path, &mut request_index)
1525}
1526
1527#[cfg(test)]
1528fn render_action_response_indexed(
1529 protocol: &WebUIProtocol,
1530 state: Value,
1531 entry_id: &str,
1532 request_path: &str,
1533 index: &mut RequestProtocolIndex<'_>,
1534) -> Value {
1535 let chain = collect_route_chain(protocol, entry_id, request_path, index.route_index);
1536
1537 let mut accumulated_params: HashMap<String, String> = HashMap::new();
1539 let mut all_invalidates: Vec<String> = Vec::new();
1540
1541 for entry in &chain {
1542 for (k, v) in &entry.params {
1543 accumulated_params.insert(k.clone(), v.clone());
1544 }
1545 let resolved = resolve_tag_templates(&entry.invalidates, &accumulated_params);
1546 all_invalidates.extend(resolved);
1547 }
1548
1549 let mut seen = HashSet::new();
1551 let deduped: Vec<Value> = all_invalidates
1552 .into_iter()
1553 .filter(|t| seen.insert(t.clone()))
1554 .map(Value::String)
1555 .collect();
1556
1557 let mut result = serde_json::Map::with_capacity(3);
1558 result.insert("state".into(), state);
1559 result.insert("invalidateTags".into(), Value::Array(deduped));
1560 result.insert("path".into(), Value::String(request_path.to_string()));
1561 Value::Object(result)
1562}
1563
1564#[cfg(test)]
1573fn render_component_templates(
1574 protocol: &WebUIProtocol,
1575 component_tags: &[&str],
1576 inventory_hex: &str,
1577 index: &mut ProtocolIndex,
1578) -> Result<Value, HandlerError> {
1579 let mut request_index = index.request_index();
1580 render_component_templates_indexed(protocol, component_tags, inventory_hex, &mut request_index)
1581}
1582
1583fn render_component_templates_indexed(
1584 protocol: &WebUIProtocol,
1585 component_tags: &[&str],
1586 inventory_hex: &str,
1587 index: &mut RequestProtocolIndex<'_>,
1588) -> Result<Value, HandlerError> {
1589 let mut seen: HashSet<&str> = HashSet::new();
1591 let requested: Vec<String> = component_tags
1592 .iter()
1593 .filter(|s| seen.insert(**s))
1594 .map(|s| (*s).to_string())
1595 .collect();
1596 let (needed, updated_inv) =
1597 filter_needed_components(&requested, inventory_hex, index.component_index)?;
1598
1599 let tag_refs: Vec<&str> = needed.iter().map(|s| s.as_str()).collect();
1600 let assets = collect_component_assets(protocol, &tag_refs, index)?;
1601
1602 let mut result = serde_json::Map::with_capacity(4);
1603 result.insert("templateStyles".into(), Value::Array(assets.styles));
1604 result.insert("templates".into(), Value::Object(assets.templates));
1605 result.insert("templateFunctions".into(), Value::Object(assets.functions));
1606 result.insert("inventory".into(), Value::String(updated_inv));
1607 Ok(Value::Object(result))
1608}
1609
1610fn collect_component_assets(
1612 protocol: &WebUIProtocol,
1613 tags: &[&str],
1614 index: &mut RequestProtocolIndex<'_>,
1615) -> Result<ComponentAssets, HandlerError> {
1616 let mut style_array = Vec::new();
1617 let mut tmpl_map = serde_json::Map::new();
1618 let mut function_map = serde_json::Map::new();
1619
1620 let mut sorted_tags: Vec<&str> = tags.to_vec();
1622 sorted_tags.sort_unstable();
1623
1624 for tag in sorted_tags {
1625 let Some(component) = protocol.components.get(tag) else {
1626 continue;
1627 };
1628 if !has_template_payload(component) {
1629 continue;
1630 }
1631 if !component.css.is_empty() {
1632 let tag_html = crate::css_module::build_importmap_tag(tag, &component.css, None);
1636 style_array.push(Value::String(tag_html));
1637 }
1638 if !component.template_json.is_empty() {
1639 let template_value = cached_template_metadata(
1640 &mut index.template_metadata_cache,
1641 tag,
1642 &component.template_json,
1643 )?;
1644 tmpl_map.insert(tag.to_string(), template_value);
1645 if !component.template_functions.is_empty() {
1646 function_map.insert(
1647 tag.to_string(),
1648 Value::String(component.template_functions.clone()),
1649 );
1650 }
1651 } else {
1652 tmpl_map.insert(tag.to_string(), Value::String(component.template.clone()));
1653 }
1654 }
1655
1656 Ok(ComponentAssets {
1657 styles: style_array,
1658 templates: tmpl_map,
1659 functions: function_map,
1660 })
1661}
1662
1663fn cached_template_metadata(
1664 cache: &mut TemplateMetadataCache<'_>,
1665 tag: &str,
1666 template_json: &str,
1667) -> Result<Value, HandlerError> {
1668 match cache {
1669 #[cfg(test)]
1670 TemplateMetadataCache::Exclusive(cache) => {
1671 cached_template_metadata_exclusive(cache, tag, template_json)
1672 }
1673 TemplateMetadataCache::Shared(cache) => {
1674 if let Some(value) = cache
1675 .read()
1676 .map_err(|_| protocol_metadata_cache_poisoned())?
1677 .get(tag)
1678 .cloned()
1679 {
1680 return Ok(value);
1681 }
1682
1683 let value = parse_template_metadata(tag, template_json)?;
1684 let mut cache = cache
1685 .write()
1686 .map_err(|_| protocol_metadata_cache_poisoned())?;
1687 Ok(cache.entry(tag.to_string()).or_insert(value).clone())
1688 }
1689 }
1690}
1691
1692#[cfg(test)]
1693fn cached_template_metadata_exclusive(
1694 cache: &mut HashMap<String, Value>,
1695 tag: &str,
1696 template_json: &str,
1697) -> Result<Value, HandlerError> {
1698 if let Some(value) = cache.get(tag) {
1699 return Ok(value.clone());
1700 }
1701
1702 let value = parse_template_metadata(tag, template_json)?;
1703 cache.insert(tag.to_string(), value.clone());
1704 Ok(value)
1705}
1706
1707fn parse_template_metadata(tag: &str, template_json: &str) -> Result<Value, HandlerError> {
1708 serde_json::from_str(template_json).map_err(|error| {
1709 HandlerError::Rendering(format!(
1710 "failed to parse template metadata for {tag}: {error}"
1711 ))
1712 })
1713}
1714
1715#[cold]
1716#[inline(never)]
1717fn protocol_metadata_cache_poisoned() -> HandlerError {
1718 HandlerError::Invariant(
1719 "protocol metadata cache is unavailable after a previous panic".to_string(),
1720 )
1721}
1722
1723#[derive(Debug, Clone)]
1727pub struct RouteChainEntry {
1728 pub component: String,
1730 pub path: String,
1732 pub params: HashMap<String, String>,
1734 pub exact: bool,
1736 pub allowed_query: String,
1738 pub keep_alive: bool,
1740 pub cache_tags: Vec<String>,
1742 pub invalidates: Vec<String>,
1744 pub pending_component: String,
1746 pub error_component: String,
1748}
1749
1750impl RouteChainEntry {
1751 #[must_use]
1753 pub fn to_json(&self) -> Value {
1754 let mut obj = serde_json::Map::with_capacity(9);
1755 obj.insert("component".into(), Value::String(self.component.clone()));
1756 obj.insert("path".into(), Value::String(self.path.clone()));
1757 if !self.params.is_empty() {
1758 let params_obj: serde_json::Map<String, Value> = self
1759 .params
1760 .iter()
1761 .map(|(k, v)| (k.clone(), Value::String(v.clone())))
1762 .collect();
1763 obj.insert("params".into(), Value::Object(params_obj));
1764 }
1765 if self.exact {
1766 obj.insert("exact".into(), Value::Bool(true));
1767 }
1768 if !self.allowed_query.is_empty() {
1769 obj.insert(
1770 "allowedQuery".into(),
1771 Value::String(self.allowed_query.clone()),
1772 );
1773 }
1774 if self.keep_alive {
1775 obj.insert("keepAlive".into(), Value::Bool(true));
1776 }
1777 if !self.pending_component.is_empty() {
1778 obj.insert(
1779 "pendingComponent".into(),
1780 Value::String(self.pending_component.clone()),
1781 );
1782 }
1783 if !self.error_component.is_empty() {
1784 obj.insert(
1785 "errorComponent".into(),
1786 Value::String(self.error_component.clone()),
1787 );
1788 }
1789 if !self.invalidates.is_empty() {
1790 obj.insert(
1791 "invalidates".into(),
1792 Value::Array(
1793 self.invalidates
1794 .iter()
1795 .map(|s| Value::String(s.clone()))
1796 .collect(),
1797 ),
1798 );
1799 }
1800 Value::Object(obj)
1801 }
1802}
1803
1804pub(crate) fn collect_route_chain(
1809 protocol: &WebUIProtocol,
1810 entry_id: &str,
1811 request_path: &str,
1812 route_index: &CompiledRouteIndex,
1813) -> Vec<RouteChainEntry> {
1814 let mut chain = Vec::new();
1815 let mut visited_fragments = HashSet::new();
1816 let mut stack = vec![QueuedFragment {
1817 id: entry_id.to_string(),
1818 inventoryable: false,
1819 route_base: "/".to_string(),
1820 }];
1821
1822 while let Some(queued) = stack.pop() {
1823 if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
1824 continue;
1825 }
1826
1827 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1828 continue;
1829 };
1830
1831 let matched_route = route_renderer::find_best_route_match(
1832 &frag_list.fragments,
1833 request_path,
1834 &queued.route_base,
1835 route_index,
1836 );
1837
1838 for frag in &frag_list.fragments {
1839 match frag.fragment.as_ref() {
1840 Some(Fragment::Component(component)) => {
1841 stack.push(QueuedFragment {
1842 id: component.fragment_id.clone(),
1843 inventoryable: false,
1844 route_base: queued.route_base.clone(),
1845 });
1846 }
1847 Some(Fragment::Route(route_frag)) => {
1848 let is_selected = matched_route
1849 .as_ref()
1850 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1851 if is_selected && !route_frag.fragment_id.is_empty() {
1852 if let Some((_, ref rm)) = matched_route {
1853 chain.push(RouteChainEntry {
1854 component: route_frag.fragment_id.clone(),
1855 path: route_frag.path.clone(),
1856 params: rm.params.clone(),
1857 exact: route_frag.exact,
1858 allowed_query: route_frag.allowed_query.clone(),
1859 keep_alive: route_frag.keep_alive,
1860 cache_tags: route_frag.cache_tags.clone(),
1861 invalidates: route_frag.invalidates.clone(),
1862 pending_component: route_frag.pending_component.clone(),
1863 error_component: route_frag.error_component.clone(),
1864 });
1865
1866 let child_route_base = route_matcher::compute_route_base(
1867 request_path,
1868 rm.consumed_segments,
1869 );
1870
1871 stack.push(QueuedFragment {
1872 id: route_frag.fragment_id.clone(),
1873 inventoryable: false,
1874 route_base: child_route_base.clone(),
1875 });
1876
1877 collect_chain_from_children(
1879 &route_frag.children,
1880 &child_route_base,
1881 &mut chain,
1882 &mut ChildWalkCtx {
1883 request_path,
1884 protocol,
1885 route_index,
1886 },
1887 );
1888 }
1889 }
1890 }
1891 _ => {}
1892 }
1893 }
1894 }
1895
1896 chain
1897}
1898
1899fn collect_chain_from_children(
1901 children: &[WebUIFragmentRoute],
1902 route_base: &str,
1903 chain: &mut Vec<RouteChainEntry>,
1904 ctx: &mut ChildWalkCtx<'_>,
1905) {
1906 let mut pending: Vec<(&[WebUIFragmentRoute], String)> =
1907 vec![(children, route_base.to_string())];
1908
1909 while let Some((current, base)) = pending.pop() {
1910 if let Some((idx, rm)) =
1911 select_best_child_route(current, ctx.request_path, &base, ctx.route_index)
1912 {
1913 let matched = ¤t[idx];
1914 chain.push(RouteChainEntry {
1915 component: matched.fragment_id.clone(),
1916 path: matched.path.clone(),
1917 params: rm.params,
1918 exact: matched.exact,
1919 allowed_query: matched.allowed_query.clone(),
1920 keep_alive: matched.keep_alive,
1921 cache_tags: matched.cache_tags.clone(),
1922 invalidates: matched.invalidates.clone(),
1923 pending_component: matched.pending_component.clone(),
1924 error_component: matched.error_component.clone(),
1925 });
1926 if !matched.children.is_empty() {
1927 let child_base =
1928 route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
1929 pending.push((&matched.children, child_base));
1930 }
1931 }
1932 }
1933}
1934
1935#[cfg(test)]
1936mod tests {
1937 use super::*;
1938 use std::collections::HashMap;
1939 use std::sync::{Arc, Barrier};
1940 use std::thread;
1941 use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
1942
1943 #[test]
1944 fn protocol_decodes_once_and_exposes_tokens() {
1945 let mut fragments = HashMap::new();
1946 fragments.insert(
1947 "index.html".to_string(),
1948 FragmentList {
1949 fragments: vec![WebUIFragment::raw("<p>Prepared</p>")],
1950 },
1951 );
1952 let protocol = WebUIProtocol::with_tokens(fragments, vec!["colorBrand".to_string()]);
1953 let bytes = protocol.to_protobuf().unwrap();
1954 let prepared = Protocol::from_protobuf(&bytes).unwrap();
1955
1956 assert!(prepared.protocol().fragments.contains_key("index.html"));
1957 assert_eq!(prepared.tokens(), ["colorBrand"]);
1958 }
1959
1960 #[test]
1961 fn protocol_partial_matches_direct_index_path() {
1962 let mut fragments = HashMap::new();
1963 fragments.insert(
1964 "index.html".to_string(),
1965 FragmentList {
1966 fragments: vec![WebUIFragment::route("/", "home-page")],
1967 },
1968 );
1969 fragments.insert(
1970 "home-page".to_string(),
1971 FragmentList {
1972 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
1973 },
1974 );
1975 let protocol = WebUIProtocol::new(fragments);
1976 let mut direct_index = ProtocolIndex::new(&protocol);
1977 let direct =
1978 render_partial_metadata(&protocol, "index.html", "/", "", &mut direct_index).unwrap();
1979 let prepared = Protocol::new(protocol);
1980 let cached = prepared
1981 .render_partial_metadata("index.html", "/", "")
1982 .unwrap();
1983
1984 assert_eq!(cached, direct);
1985 }
1986
1987 #[test]
1988 fn protocol_partial_supports_concurrent_metadata_reads() {
1989 let mut fragments = HashMap::new();
1990 fragments.insert(
1991 "index.html".to_string(),
1992 FragmentList {
1993 fragments: vec![WebUIFragment::route("/", "home-page")],
1994 },
1995 );
1996 fragments.insert(
1997 "home-page".to_string(),
1998 FragmentList {
1999 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
2000 },
2001 );
2002 let mut protocol = WebUIProtocol::new(fragments);
2003 protocol.components.insert(
2004 "home-page".to_string(),
2005 webui_protocol::ComponentData {
2006 template_json: r#"{"h":"<p>Home</p>"}"#.to_string(),
2007 ..Default::default()
2008 },
2009 );
2010
2011 let prepared = Arc::new(Protocol::new(protocol));
2012 let barrier = Arc::new(Barrier::new(4));
2013 let handles: Vec<_> = (0..4)
2014 .map(|_| {
2015 let prepared = Arc::clone(&prepared);
2016 let barrier = Arc::clone(&barrier);
2017 thread::spawn(move || {
2018 barrier.wait();
2019 for _ in 0..50 {
2020 let response = prepared
2021 .render_partial_metadata("index.html", "/", "")
2022 .unwrap();
2023 assert_eq!(response["templates"]["home-page"]["h"], "<p>Home</p>");
2024 }
2025 })
2026 })
2027 .collect();
2028
2029 for handle in handles {
2030 handle.join().unwrap();
2031 }
2032 }
2033
2034 fn prepared_partial_protocol(hydration_keys: &[&str]) -> Protocol {
2035 let mut fragments = HashMap::new();
2036 fragments.insert(
2037 "index.html".to_string(),
2038 FragmentList {
2039 fragments: vec![WebUIFragment::route("/", "home-page")],
2040 },
2041 );
2042 fragments.insert(
2043 "home-page".to_string(),
2044 FragmentList {
2045 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
2046 },
2047 );
2048 let mut protocol = WebUIProtocol::new(fragments);
2049 protocol.components.insert(
2050 "home-page".to_string(),
2051 webui_protocol::ComponentData {
2052 template_json: r#"{"h":"<p>Home</p>"}"#.to_string(),
2053 hydration_mode: webui_protocol::StateProjectionMode::Keys as i32,
2054 hydration_keys: hydration_keys
2055 .iter()
2056 .map(|key| (*key).to_string())
2057 .collect(),
2058 navigation_mode: webui_protocol::StateProjectionMode::Keys as i32,
2059 navigation_keys: hydration_keys
2060 .iter()
2061 .map(|key| (*key).to_string())
2062 .collect(),
2063 ..Default::default()
2064 },
2065 );
2066 Protocol::new(protocol)
2067 }
2068
2069 fn prepared_full_state_partial_protocol() -> Protocol {
2070 let mut prepared = prepared_partial_protocol(&[]);
2071 let protocol = &mut prepared.protocol;
2072 if let Some(component) = protocol.components.get_mut("home-page") {
2073 component.navigation_mode = webui_protocol::StateProjectionMode::All as i32;
2074 component.navigation_keys.clear();
2075 }
2076 prepared
2077 }
2078
2079 #[test]
2080 fn partial_state_serialization_preserves_validated_raw_json() {
2081 let prepared = prepared_partial_protocol(&["value"]);
2082 let output = prepared
2083 .render_partial(
2084 r#"{"serverOnly":"drop","value":1e2}"#,
2085 "index.html",
2086 "/",
2087 "",
2088 )
2089 .unwrap();
2090 assert!(output.contains(r#""state":{"value":1e2}"#));
2091 assert!(!output.contains("serverOnly"));
2092
2093 let parsed: Value = serde_json::from_str(&output).unwrap();
2094 assert_eq!(parsed["state"]["value"], 100.0);
2095 }
2096
2097 #[test]
2098 fn uncertain_partial_surface_preserves_complete_raw_state() {
2099 let prepared = prepared_full_state_partial_protocol();
2100 let output = prepared
2101 .render_partial(
2102 r#"{"serverOnly":"keep","value":1e2}"#,
2103 "index.html",
2104 "/",
2105 "",
2106 )
2107 .unwrap();
2108 assert!(output.contains(r#""state":{"serverOnly":"keep","value":1e2}"#));
2109 }
2110
2111 #[test]
2112 fn partial_state_serialization_rejects_invalid_json() {
2113 let prepared = prepared_partial_protocol(&[]);
2114 let error = prepared
2115 .render_partial(r#"{"broken":"#, "index.html", "/", "")
2116 .expect_err("invalid state JSON must fail");
2117 assert!(error.to_string().contains("invalid state JSON"));
2118 }
2119
2120 #[test]
2121 fn partial_state_serialization_emits_empty_state_without_client_components() {
2122 let prepared = Protocol::new(WebUIProtocol::default());
2123 let output = prepared
2124 .render_partial(r#"{"serverOnly":"drop"}"#, "index.html", "/", "")
2125 .unwrap();
2126 let parsed: Value = serde_json::from_str(&output).unwrap();
2127 assert_eq!(parsed["state"], serde_json::json!({}));
2128 }
2129
2130 #[test]
2131 fn partial_state_serialization_validates_selected_and_skipped_numbers() {
2132 let prepared = prepared_partial_protocol(&["value"]);
2133 for state in [r#"{"value":1e9999}"#, r#"{"serverOnly":1e9999,"value":1}"#] {
2134 let error = prepared
2135 .render_partial(state, "index.html", "/", "")
2136 .expect_err("out-of-range state numbers must fail");
2137 assert!(error.to_string().contains("invalid state JSON"));
2138 }
2139 }
2140
2141 #[test]
2142 fn partial_state_serialization_uses_last_duplicate_and_decodes_keys() {
2143 let prepared = prepared_partial_protocol(&["value"]);
2144 let output = prepared
2145 .render_partial(r#"{"value":1,"va\u006cue":2}"#, "index.html", "/", "")
2146 .unwrap();
2147 let parsed: Value = serde_json::from_str(&output).unwrap();
2148 assert_eq!(parsed["state"], serde_json::json!({"value": 2}));
2149 }
2150
2151 #[test]
2152 fn parsed_partial_state_projects_without_cloning_server_only_values() {
2153 let prepared = prepared_partial_protocol(&["value"]);
2154 let response = render_partial(
2155 prepared.protocol(),
2156 serde_json::json!({"serverOnly": [1, 2, 3], "value": "kept"}),
2157 "index.html",
2158 "/",
2159 "",
2160 )
2161 .unwrap();
2162 assert_eq!(response["state"], serde_json::json!({"value": "kept"}));
2163 }
2164
2165 #[test]
2166 fn uncertain_partial_surface_preserves_complete_parsed_state() {
2167 let prepared = prepared_full_state_partial_protocol();
2168 let state = serde_json::json!({"serverOnly": [1, 2, 3], "value": "kept"});
2169 let response =
2170 render_partial(prepared.protocol(), state.clone(), "index.html", "/", "").unwrap();
2171 assert_eq!(response["state"], state);
2172 }
2173
2174 #[test]
2175 fn partial_state_validation_matches_value_number_limits() {
2176 for json in [
2177 r#"{"value":1e9999}"#,
2178 r#"{"value":-1e9999}"#,
2179 r#"{"value":1e308}"#,
2180 r#"{"value":18446744073709551615}"#,
2181 r#"{"value":18446744073709551616}"#,
2182 ] {
2183 assert_eq!(
2184 validate_json(json).is_ok(),
2185 serde_json::from_str::<Value>(json).is_ok(),
2186 "validation mismatch for {json}"
2187 );
2188 }
2189 }
2190
2191 #[test]
2192 fn test_parse_encode_inventory_roundtrip() {
2193 let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
2194 let hex = encode_inventory(&original);
2195 let decoded = parse_inventory(&hex).unwrap();
2196 assert_eq!(original, decoded);
2197 }
2198
2199 #[test]
2200 fn test_parse_inventory_empty() {
2201 let result = parse_inventory("").unwrap();
2202 assert!(result.is_empty());
2203 }
2204
2205 #[test]
2206 fn test_parse_inventory_rejects_odd_length() {
2207 let result = parse_inventory("abc");
2208 assert!(result.is_err());
2209 }
2210
2211 #[test]
2212 fn test_parse_inventory_rejects_invalid_hex() {
2213 let result = parse_inventory("zz");
2214 assert!(result.is_err());
2215 }
2216
2217 #[test]
2218 fn test_bitfield_set_and_check() {
2219 let mut inv = vec![0u8; 4];
2220 set_component(&mut inv, 0);
2221 set_component(&mut inv, 7);
2222 set_component(&mut inv, 8);
2223 set_component(&mut inv, 15);
2224 assert!(has_component(&inv, 0));
2225 assert!(has_component(&inv, 7));
2226 assert!(has_component(&inv, 8));
2227 assert!(has_component(&inv, 15));
2228 assert!(!has_component(&inv, 1));
2229 assert!(!has_component(&inv, 9));
2230 }
2231
2232 #[test]
2233 fn test_filter_needed_components_no_false_positives() {
2234 let mut index = HashMap::new();
2236 index.insert("email-message".to_string(), 0);
2237 index.insert("o-button".to_string(), 1);
2238 index.insert("o-avatar".to_string(), 2);
2239
2240 let names = vec!["email-message".to_string(), "o-button".to_string()];
2241
2242 let mut inv = vec![0u8; 1];
2244 set_component(&mut inv, 1); let inv_hex = encode_inventory(&inv);
2246
2247 let (needed, _) = filter_needed_components(&names, &inv_hex, &index).unwrap();
2248 assert_eq!(needed.len(), 1);
2249 assert!(
2250 needed.contains(&"email-message".to_string()),
2251 "email-message must be needed: {needed:?}"
2252 );
2253 }
2254
2255 #[test]
2256 fn test_get_needed_components_empty_inventory() {
2257 let mut fragments = HashMap::new();
2258 fragments.insert(
2259 "app-shell".to_string(),
2260 FragmentList {
2261 fragments: vec![WebUIFragment::component("my-card")],
2262 },
2263 );
2264 fragments.insert(
2265 "my-card".to_string(),
2266 FragmentList {
2267 fragments: vec![WebUIFragment::raw("card")],
2268 },
2269 );
2270
2271 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2272 let comp_index = build_component_index(&protocol);
2273 let (needed, _inv) =
2274 get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
2275 assert!(needed.contains(&"app-shell".to_string()));
2276 assert!(needed.contains(&"my-card".to_string()));
2277 }
2278
2279 #[test]
2280 fn test_get_needed_components_skips_inventory() {
2281 let mut fragments = HashMap::new();
2282 fragments.insert(
2283 "app-shell".to_string(),
2284 FragmentList {
2285 fragments: vec![WebUIFragment::component("my-card")],
2286 },
2287 );
2288 fragments.insert(
2289 "my-card".to_string(),
2290 FragmentList {
2291 fragments: vec![WebUIFragment::raw("card")],
2292 },
2293 );
2294
2295 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2296 protocol
2297 .components
2298 .entry("app-shell".to_string())
2299 .or_default()
2300 .template = "<t></t>".to_string();
2301 protocol
2302 .components
2303 .entry("my-card".to_string())
2304 .or_default()
2305 .template = "<t></t>".to_string();
2306
2307 let comp_index = build_component_index(&protocol);
2308 let (_needed, inv_hex) =
2309 get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
2310 let (needed2, _) =
2311 get_needed_components(&protocol, "app-shell", &inv_hex, &comp_index).unwrap();
2312 assert!(needed2.is_empty());
2313 }
2314
2315 #[test]
2316 fn test_get_needed_components_skips_control_fragments() {
2317 let mut fragments = HashMap::new();
2318 fragments.insert(
2319 "app-shell".to_string(),
2320 FragmentList {
2321 fragments: vec![
2322 WebUIFragment::if_cond(
2323 webui_protocol::ConditionExpr::identifier("showNav"),
2324 "if-shell",
2325 ),
2326 WebUIFragment::for_loop("item", "items", "for-items"),
2327 WebUIFragment::attribute_template("title", "attr-title"),
2328 ],
2329 },
2330 );
2331 fragments.insert(
2332 "if-shell".to_string(),
2333 FragmentList {
2334 fragments: vec![WebUIFragment::component("mp-category-nav")],
2335 },
2336 );
2337 fragments.insert(
2338 "for-items".to_string(),
2339 FragmentList {
2340 fragments: vec![WebUIFragment::component("mp-product-card")],
2341 },
2342 );
2343 fragments.insert(
2344 "attr-title".to_string(),
2345 FragmentList {
2346 fragments: vec![WebUIFragment::raw("Products")],
2347 },
2348 );
2349 fragments.insert(
2350 "mp-category-nav".to_string(),
2351 FragmentList {
2352 fragments: vec![WebUIFragment::raw("<nav></nav>")],
2353 },
2354 );
2355 fragments.insert(
2356 "mp-product-card".to_string(),
2357 FragmentList {
2358 fragments: vec![WebUIFragment::raw("<article></article>")],
2359 },
2360 );
2361
2362 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2363 let comp_index = build_component_index(&protocol);
2364 let needed: HashSet<String> =
2365 get_needed_components(&protocol, "app-shell", "", &comp_index)
2366 .unwrap()
2367 .0
2368 .into_iter()
2369 .collect();
2370
2371 assert!(needed.contains("app-shell"));
2372 assert!(needed.contains("mp-category-nav"));
2373 assert!(needed.contains("mp-product-card"));
2374 assert!(!needed.contains("if-shell"));
2375 assert!(!needed.contains("for-items"));
2376 assert!(!needed.contains("attr-title"));
2377 }
2378
2379 #[test]
2380 fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
2381 let mut fragments = HashMap::new();
2382 fragments.insert(
2383 "index.html".to_string(),
2384 FragmentList {
2385 fragments: vec![WebUIFragment::component("mp-app")],
2386 },
2387 );
2388 fragments.insert(
2389 "mp-app".to_string(),
2390 FragmentList {
2391 fragments: vec![
2392 WebUIFragment::component("mp-category-nav"),
2393 WebUIFragment::route_from(WebUiFragmentRoute {
2394 path: "/search/:category".into(),
2395 fragment_id: "mp-search-page".into(),
2396 exact: true,
2397 keep_alive: false,
2398 ..Default::default()
2399 }),
2400 WebUIFragment::route_from(WebUiFragmentRoute {
2401 path: "/product/:handle".into(),
2402 fragment_id: "mp-product-page".into(),
2403 exact: true,
2404 keep_alive: false,
2405 ..Default::default()
2406 }),
2407 ],
2408 },
2409 );
2410 fragments.insert(
2411 "mp-category-nav".to_string(),
2412 FragmentList {
2413 fragments: vec![WebUIFragment::raw("<nav></nav>")],
2414 },
2415 );
2416 fragments.insert(
2417 "mp-search-page".to_string(),
2418 FragmentList {
2419 fragments: vec![WebUIFragment::component("mp-product-grid")],
2420 },
2421 );
2422 fragments.insert(
2423 "mp-product-grid".to_string(),
2424 FragmentList {
2425 fragments: vec![WebUIFragment::raw("<div>grid</div>")],
2426 },
2427 );
2428 fragments.insert(
2429 "mp-product-page".to_string(),
2430 FragmentList {
2431 fragments: vec![WebUIFragment::component("mp-product-detail")],
2432 },
2433 );
2434 fragments.insert(
2435 "mp-product-detail".to_string(),
2436 FragmentList {
2437 fragments: vec![WebUIFragment::raw("<div>detail</div>")],
2438 },
2439 );
2440
2441 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2442 protocol
2443 .components
2444 .entry("mp-search-page".to_string())
2445 .or_default()
2446 .template = "<mp-search-page></mp-search-page>".to_string();
2447 protocol
2448 .components
2449 .entry("mp-product-page".to_string())
2450 .or_default()
2451 .template = "<mp-product-page></mp-product-page>".to_string();
2452
2453 let comp_index = build_component_index(&protocol);
2454 let (needed, _inv) = get_needed_components_for_request(
2455 &protocol,
2456 "index.html",
2457 "/search/shirts",
2458 "",
2459 (&comp_index, &CompiledRouteIndex::new(&protocol)),
2460 )
2461 .unwrap();
2462 let needed: HashSet<String> = needed.into_iter().collect();
2463
2464 assert!(needed.contains("mp-app"));
2465 assert!(needed.contains("mp-category-nav"));
2466 assert!(needed.contains("mp-search-page"));
2467 assert!(needed.contains("mp-product-grid"));
2468 assert!(!needed.contains("mp-product-page"));
2469 assert!(!needed.contains("mp-product-detail"));
2470 }
2471
2472 #[test]
2473 fn test_get_needed_components_for_request_follows_nested_route_chain() {
2474 let mut fragments = HashMap::new();
2475 fragments.insert(
2476 "index.html".to_string(),
2477 FragmentList {
2478 fragments: vec![WebUIFragment::component("mp-app")],
2479 },
2480 );
2481 fragments.insert(
2482 "mp-app".to_string(),
2483 FragmentList {
2484 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2485 path: "/account/*rest".into(),
2486 fragment_id: "mp-account-shell".into(),
2487 exact: false,
2488 keep_alive: false,
2489 ..Default::default()
2490 })],
2491 },
2492 );
2493 fragments.insert(
2494 "mp-account-shell".to_string(),
2495 FragmentList {
2496 fragments: vec![
2497 WebUIFragment::component("mp-account-nav"),
2498 WebUIFragment::route_from(WebUiFragmentRoute {
2499 path: "/account/profile".into(),
2500 fragment_id: "mp-profile-page".into(),
2501 exact: true,
2502 keep_alive: false,
2503 ..Default::default()
2504 }),
2505 WebUIFragment::route_from(WebUiFragmentRoute {
2506 path: "/account/orders/:id".into(),
2507 fragment_id: "mp-order-page".into(),
2508 exact: true,
2509 keep_alive: false,
2510 ..Default::default()
2511 }),
2512 ],
2513 },
2514 );
2515 fragments.insert(
2516 "mp-account-nav".to_string(),
2517 FragmentList {
2518 fragments: vec![WebUIFragment::raw("<nav></nav>")],
2519 },
2520 );
2521 fragments.insert(
2522 "mp-profile-page".to_string(),
2523 FragmentList {
2524 fragments: vec![WebUIFragment::raw("<profile></profile>")],
2525 },
2526 );
2527 fragments.insert(
2528 "mp-order-page".to_string(),
2529 FragmentList {
2530 fragments: vec![WebUIFragment::component("mp-order-detail")],
2531 },
2532 );
2533 fragments.insert(
2534 "mp-order-detail".to_string(),
2535 FragmentList {
2536 fragments: vec![WebUIFragment::raw("<detail></detail>")],
2537 },
2538 );
2539
2540 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2541 protocol
2542 .components
2543 .entry("mp-account-shell".to_string())
2544 .or_default()
2545 .template = "<mp-account-shell></mp-account-shell>".to_string();
2546 protocol
2547 .components
2548 .entry("mp-profile-page".to_string())
2549 .or_default()
2550 .template = "<mp-profile-page></mp-profile-page>".to_string();
2551 protocol
2552 .components
2553 .entry("mp-order-page".to_string())
2554 .or_default()
2555 .template = "<mp-order-page></mp-order-page>".to_string();
2556
2557 let comp_index = build_component_index(&protocol);
2558 let (needed, _inv) = get_needed_components_for_request(
2559 &protocol,
2560 "index.html",
2561 "/account/orders/42",
2562 "",
2563 (&comp_index, &CompiledRouteIndex::new(&protocol)),
2564 )
2565 .unwrap();
2566 let needed: HashSet<String> = needed.into_iter().collect();
2567
2568 assert!(needed.contains("mp-app"));
2569 assert!(needed.contains("mp-account-shell"));
2570 assert!(needed.contains("mp-account-nav"));
2571 assert!(needed.contains("mp-order-page"));
2572 assert!(needed.contains("mp-order-detail"));
2573 assert!(!needed.contains("mp-profile-page"));
2574 }
2575
2576 #[test]
2577 fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
2578 let mut fragments = HashMap::new();
2579 fragments.insert(
2580 "index.html".to_string(),
2581 FragmentList {
2582 fragments: vec![WebUIFragment::component("mp-app")],
2583 },
2584 );
2585 fragments.insert(
2586 "mp-app".to_string(),
2587 FragmentList {
2588 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2589 path: "/search".into(),
2590 fragment_id: "mp-search-page".into(),
2591 exact: true,
2592 keep_alive: false,
2593 ..Default::default()
2594 })],
2595 },
2596 );
2597 fragments.insert(
2598 "mp-search-page".to_string(),
2599 FragmentList {
2600 fragments: vec![
2601 WebUIFragment::if_cond(
2602 webui_protocol::ConditionExpr::identifier("showFilters"),
2603 "if-filters",
2604 ),
2605 WebUIFragment::for_loop("item", "items", "item-loop"),
2606 ],
2607 },
2608 );
2609 fragments.insert(
2610 "if-filters".to_string(),
2611 FragmentList {
2612 fragments: vec![WebUIFragment::component("mp-filter-panel")],
2613 },
2614 );
2615 fragments.insert(
2616 "item-loop".to_string(),
2617 FragmentList {
2618 fragments: vec![WebUIFragment::component("mp-item-card")],
2619 },
2620 );
2621 fragments.insert(
2622 "mp-filter-panel".to_string(),
2623 FragmentList {
2624 fragments: vec![WebUIFragment::raw("<filters></filters>")],
2625 },
2626 );
2627 fragments.insert(
2628 "mp-item-card".to_string(),
2629 FragmentList {
2630 fragments: vec![WebUIFragment::raw("<item></item>")],
2631 },
2632 );
2633
2634 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2635 protocol
2636 .components
2637 .entry("mp-search-page".to_string())
2638 .or_default()
2639 .template = "<mp-search-page></mp-search-page>".to_string();
2640
2641 let comp_index = build_component_index(&protocol);
2642 let (needed, _inv) = get_needed_components_for_request(
2643 &protocol,
2644 "index.html",
2645 "/search",
2646 "",
2647 (&comp_index, &CompiledRouteIndex::new(&protocol)),
2648 )
2649 .unwrap();
2650 let needed: HashSet<String> = needed.into_iter().collect();
2651
2652 assert!(needed.contains("mp-app"));
2653 assert!(needed.contains("mp-search-page"));
2654 assert!(needed.contains("mp-filter-panel"));
2655 assert!(needed.contains("mp-item-card"));
2656 }
2657
2658 #[test]
2659 fn test_get_needed_components_for_request_respects_inventory() {
2660 let mut fragments = HashMap::new();
2661 fragments.insert(
2662 "index.html".to_string(),
2663 FragmentList {
2664 fragments: vec![WebUIFragment::component("mp-app")],
2665 },
2666 );
2667 fragments.insert(
2668 "mp-app".to_string(),
2669 FragmentList {
2670 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2671 path: "/search".into(),
2672 fragment_id: "mp-search-page".into(),
2673 exact: true,
2674 keep_alive: false,
2675 ..Default::default()
2676 })],
2677 },
2678 );
2679 fragments.insert(
2680 "mp-search-page".to_string(),
2681 FragmentList {
2682 fragments: vec![WebUIFragment::component("mp-product-grid")],
2683 },
2684 );
2685 fragments.insert(
2686 "mp-product-grid".to_string(),
2687 FragmentList {
2688 fragments: vec![WebUIFragment::raw("<grid></grid>")],
2689 },
2690 );
2691
2692 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2693 for name in ["mp-app", "mp-search-page", "mp-product-grid"] {
2694 protocol
2695 .components
2696 .entry(name.to_string())
2697 .or_default()
2698 .template = format!("<f-template id=\"{name}\"></f-template>");
2699 }
2700
2701 let comp_index = build_component_index(&protocol);
2702 let route_index = CompiledRouteIndex::new(&protocol);
2703 let (_needed, inventory) = get_needed_components_for_request(
2704 &protocol,
2705 "index.html",
2706 "/search",
2707 "",
2708 (&comp_index, &route_index),
2709 )
2710 .unwrap();
2711 let (needed_again, _) = get_needed_components_for_request(
2712 &protocol,
2713 "index.html",
2714 "/search",
2715 &inventory,
2716 (&comp_index, &route_index),
2717 )
2718 .unwrap();
2719 assert!(needed_again.is_empty());
2720 }
2721
2722 #[test]
2723 fn test_inventory_includes_boundary_components_for_unmatched_sibling_routes() {
2724 let mut fragments = HashMap::new();
2730 fragments.insert(
2731 "index.html".to_string(),
2732 FragmentList {
2733 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2734 path: "/".into(),
2735 fragment_id: "route-shell".into(),
2736 children: vec![
2737 WebUiFragmentRoute {
2738 path: "slow".into(),
2739 fragment_id: "page-slow".into(),
2740 exact: true,
2741 pending_component: "loading-skeleton".into(),
2742 ..Default::default()
2743 },
2744 WebUiFragmentRoute {
2745 path: "failing".into(),
2746 fragment_id: "page-failing".into(),
2747 exact: true,
2748 error_component: "error-display".into(),
2749 ..Default::default()
2750 },
2751 ],
2752 ..Default::default()
2753 })],
2754 },
2755 );
2756 for id in [
2757 "route-shell",
2758 "page-slow",
2759 "page-failing",
2760 "loading-skeleton",
2761 "error-display",
2762 ] {
2763 fragments.insert(
2764 id.to_string(),
2765 FragmentList {
2766 fragments: vec![WebUIFragment::raw("<x></x>")],
2767 },
2768 );
2769 }
2770
2771 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2772 for id in [
2773 "route-shell",
2774 "page-slow",
2775 "page-failing",
2776 "loading-skeleton",
2777 "error-display",
2778 ] {
2779 protocol
2780 .components
2781 .entry(id.to_string())
2782 .or_default()
2783 .template = format!("<f-template id=\"{id}\"></f-template>");
2784 }
2785
2786 let reachable = collect_reachable_components_for_request(
2788 &protocol,
2789 "index.html",
2790 "/",
2791 &CompiledRouteIndex::new(&protocol),
2792 );
2793 assert!(
2794 reachable.contains("loading-skeleton"),
2795 "pending component of an unmatched sibling route must be inventoried: {reachable:?}"
2796 );
2797 assert!(
2798 reachable.contains("error-display"),
2799 "error component of an unmatched sibling route must be inventoried: {reachable:?}"
2800 );
2801 }
2802
2803 #[test]
2804 fn test_render_partial_separates_module_styles_from_templates() {
2805 let mut fragments = HashMap::new();
2806 fragments.insert(
2807 "index.html".to_string(),
2808 FragmentList {
2809 fragments: vec![WebUIFragment::component("my-page")],
2810 },
2811 );
2812 fragments.insert(
2813 "my-page".to_string(),
2814 FragmentList {
2815 fragments: vec![WebUIFragment::raw("<p>page</p>")],
2816 },
2817 );
2818
2819 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2820 let component = protocol
2821 .components
2822 .entry("my-page".to_string())
2823 .or_default();
2824 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
2825 component.css = ".page{color:red}".to_string();
2826
2827 let mut index = ProtocolIndex::new(&protocol);
2828 let partial =
2829 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
2830 let styles = partial["templateStyles"]
2831 .as_array()
2832 .expect("templateStyles should be an array");
2833 assert_eq!(styles.len(), 1);
2834 let style_html = styles[0].as_str().unwrap_or_default();
2835 assert!(
2836 style_html.starts_with(r#"<script type="importmap""#)
2837 && style_html.contains(r#""my-page":"data:text/css,"#),
2838 "module style entry should be an importmap registering the component specifier: {style_html}"
2839 );
2840 assert!(
2841 style_html.contains(".page{color:red}"),
2842 "module style entry should contain the CSS content verbatim inside the data: URI: {style_html}"
2843 );
2844
2845 let templates = partial["templates"]
2847 .as_object()
2848 .expect("templates should be an object");
2849 assert_eq!(templates.len(), 1);
2850 let template = templates.get("my-page").expect("my-page template");
2851 assert_eq!(template["h"], "<p>page</p>");
2852 }
2853
2854 #[test]
2855 fn test_render_partial_link_strategy_has_empty_template_styles() {
2856 let mut fragments = HashMap::new();
2859 fragments.insert(
2860 "index.html".to_string(),
2861 FragmentList {
2862 fragments: vec![WebUIFragment::component("my-page")],
2863 },
2864 );
2865 fragments.insert(
2866 "my-page".to_string(),
2867 FragmentList {
2868 fragments: vec![WebUIFragment::raw("<p>page</p>")],
2869 },
2870 );
2871
2872 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2873 let component = protocol
2874 .components
2875 .entry("my-page".to_string())
2876 .or_default();
2877 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
2878 component.css_href = "my-page.css".to_string();
2879 let mut index = ProtocolIndex::new(&protocol);
2882 let partial =
2883 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
2884 let styles = partial["templateStyles"]
2885 .as_array()
2886 .expect("templateStyles should be an array");
2887 let templates = partial["templates"]
2888 .as_object()
2889 .expect("templates should be an object");
2890
2891 assert!(
2892 styles.is_empty(),
2893 "Link strategy should produce empty templateStyles: {styles:?}"
2894 );
2895 assert_eq!(templates.len(), 1, "should include template metadata");
2896 }
2897
2898 #[test]
2899 fn test_render_partial_style_strategy_has_empty_template_styles() {
2900 let mut fragments = HashMap::new();
2904 fragments.insert(
2905 "index.html".to_string(),
2906 FragmentList {
2907 fragments: vec![WebUIFragment::component("my-page")],
2908 },
2909 );
2910 fragments.insert(
2911 "my-page".to_string(),
2912 FragmentList {
2913 fragments: vec![WebUIFragment::raw("<p>page</p>")],
2914 },
2915 );
2916
2917 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2918 let component = protocol
2919 .components
2920 .entry("my-page".to_string())
2921 .or_default();
2922 component.template_json = r#"{"h":"<style>.p{color:red}</style><p>page</p>"}"#.to_string();
2924 let mut index = ProtocolIndex::new(&protocol);
2927 let partial =
2928 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
2929 let styles = partial["templateStyles"]
2930 .as_array()
2931 .expect("templateStyles should be an array");
2932 let templates = partial["templates"]
2933 .as_object()
2934 .expect("templates should be an object");
2935
2936 assert!(
2937 styles.is_empty(),
2938 "Style strategy should produce empty templateStyles: {styles:?}"
2939 );
2940 assert_eq!(templates.len(), 1, "should include template metadata");
2941 assert!(
2942 templates
2943 .get("my-page")
2944 .and_then(|template| template["h"].as_str())
2945 .unwrap_or_default()
2946 .contains("<style>"),
2947 "Style strategy template should contain inline <style> tag"
2948 );
2949 }
2950
2951 #[test]
2952 fn test_render_partial_empty_styles_for_no_css_components() {
2953 let mut fragments = HashMap::new();
2954 fragments.insert(
2955 "index.html".to_string(),
2956 FragmentList {
2957 fragments: vec![WebUIFragment::component("my-page")],
2958 },
2959 );
2960 fragments.insert(
2961 "my-page".to_string(),
2962 FragmentList {
2963 fragments: vec![WebUIFragment::raw("<p>page</p>")],
2964 },
2965 );
2966
2967 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2968 let component = protocol
2969 .components
2970 .entry("my-page".to_string())
2971 .or_default();
2972 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
2973 let mut index = ProtocolIndex::new(&protocol);
2976 let partial =
2977 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
2978 let styles = partial["templateStyles"]
2979 .as_array()
2980 .expect("templateStyles should be an array");
2981 assert!(
2982 styles.is_empty(),
2983 "templateStyles should be empty when components have no CSS"
2984 );
2985 }
2986
2987 #[test]
2988 fn test_render_partial_sends_styles_even_when_templates_filtered_by_inventory() {
2989 let mut fragments = HashMap::new();
2990 fragments.insert(
2991 "index.html".to_string(),
2992 FragmentList {
2993 fragments: vec![WebUIFragment::component("my-page")],
2994 },
2995 );
2996 fragments.insert(
2997 "my-page".to_string(),
2998 FragmentList {
2999 fragments: vec![WebUIFragment::raw("<p>page</p>")],
3000 },
3001 );
3002
3003 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3004 let component = protocol
3005 .components
3006 .entry("my-page".to_string())
3007 .or_default();
3008 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
3009 component.css = ".page{color:red}".to_string();
3010
3011 let mut index = ProtocolIndex::new(&protocol);
3012 let partial1 =
3014 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
3015 let inv = partial1["inventory"].as_str().unwrap_or_default();
3016 assert!(!inv.is_empty());
3017
3018 let partial2 =
3023 render_partial_metadata(&protocol, "index.html", "/", inv, &mut index).unwrap();
3024 let styles = partial2["templateStyles"]
3025 .as_array()
3026 .expect("templateStyles should be an array");
3027 let templates = partial2["templates"]
3028 .as_object()
3029 .expect("templates should be an object");
3030
3031 assert!(
3032 templates.is_empty(),
3033 "templates should be empty when inventory is full"
3034 );
3035 assert!(
3036 styles.is_empty(),
3037 "module styles should be empty when inventory is full — SSR already placed them"
3038 );
3039 }
3040
3041 #[test]
3042 fn test_non_route_siblings_included_in_needed_components() {
3043 let mut fragments = HashMap::new();
3048
3049 fragments.insert(
3051 "index.html".to_string(),
3052 FragmentList {
3053 fragments: vec![WebUIFragment::component("app-shell")],
3054 },
3055 );
3056
3057 fragments.insert(
3059 "app-shell".to_string(),
3060 FragmentList {
3061 fragments: vec![
3062 WebUIFragment::component("my-navbar"),
3063 WebUIFragment::route_from(WebUiFragmentRoute {
3064 path: "/about".into(),
3065 fragment_id: "page-about".into(),
3066 exact: true,
3067 keep_alive: false,
3068 ..Default::default()
3069 }),
3070 WebUIFragment::component("cart-panel"),
3071 ],
3072 },
3073 );
3074
3075 fragments.insert(
3077 "my-navbar".to_string(),
3078 FragmentList {
3079 fragments: vec![WebUIFragment::raw("<nav/>")],
3080 },
3081 );
3082 fragments.insert(
3083 "page-about".to_string(),
3084 FragmentList {
3085 fragments: vec![WebUIFragment::raw("<h1>About</h1>")],
3086 },
3087 );
3088 fragments.insert(
3089 "cart-panel".to_string(),
3090 FragmentList {
3091 fragments: vec![WebUIFragment::raw("<aside>Cart</aside>")],
3092 },
3093 );
3094
3095 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3096 for name in ["app-shell", "my-navbar", "page-about", "cart-panel"] {
3097 let comp = protocol.components.entry(name.to_string()).or_default();
3098 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
3099 comp.css = format!(".{name}{{display:block}}");
3100 }
3101
3102 let comp_index = build_component_index(&protocol);
3103 let (needed, _inv) = get_needed_components_for_request(
3104 &protocol,
3105 "index.html",
3106 "/about",
3107 "",
3108 (&comp_index, &CompiledRouteIndex::new(&protocol)),
3109 )
3110 .unwrap();
3111
3112 assert!(
3113 needed.contains(&"app-shell".to_string()),
3114 "app-shell should be needed: {needed:?}"
3115 );
3116 assert!(
3117 needed.contains(&"my-navbar".to_string()),
3118 "my-navbar (non-route sibling) should be needed: {needed:?}"
3119 );
3120 assert!(
3121 needed.contains(&"page-about".to_string()),
3122 "page-about (active route) should be needed: {needed:?}"
3123 );
3124 assert!(
3125 needed.contains(&"cart-panel".to_string()),
3126 "cart-panel (non-route sibling) should be needed: {needed:?}"
3127 );
3128 }
3129
3130 #[test]
3133 fn test_chain_entry_to_json_includes_allowed_query() {
3134 let entry = RouteChainEntry {
3135 component: "compose-page".into(),
3136 path: "compose".into(),
3137 params: HashMap::new(),
3138 exact: true,
3139 allowed_query: "action,to,subject".into(),
3140 keep_alive: false,
3141 cache_tags: Vec::new(),
3142 invalidates: Vec::new(),
3143 pending_component: String::new(),
3144 error_component: String::new(),
3145 };
3146 let json = entry.to_json();
3147 assert_eq!(json["allowedQuery"], "action,to,subject");
3148 }
3149
3150 #[test]
3151 fn test_chain_entry_to_json_omits_empty_allowed_query() {
3152 let entry = RouteChainEntry {
3153 component: "home-page".into(),
3154 path: "/".into(),
3155 params: HashMap::new(),
3156 exact: true,
3157 allowed_query: String::new(),
3158 keep_alive: false,
3159 cache_tags: Vec::new(),
3160 invalidates: Vec::new(),
3161 pending_component: String::new(),
3162 error_component: String::new(),
3163 };
3164 let json = entry.to_json();
3165 assert!(
3166 json.get("allowedQuery").is_none(),
3167 "empty allowed_query should not appear in JSON"
3168 );
3169 }
3170
3171 #[test]
3172 fn test_collect_route_chain_carries_allowed_query() {
3173 let mut fragments = HashMap::new();
3174 fragments.insert(
3175 "index.html".to_string(),
3176 FragmentList {
3177 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3178 path: "/".into(),
3179 fragment_id: "app-shell".into(),
3180 exact: false,
3181 children: vec![WebUiFragmentRoute {
3182 path: "compose".into(),
3183 fragment_id: "compose-page".into(),
3184 exact: true,
3185 allowed_query: "action,to".into(),
3186 keep_alive: false,
3187 ..Default::default()
3188 }],
3189 keep_alive: false,
3190 ..Default::default()
3191 })],
3192 },
3193 );
3194 fragments.insert(
3195 "app-shell".to_string(),
3196 FragmentList {
3197 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
3198 },
3199 );
3200 fragments.insert(
3201 "compose-page".to_string(),
3202 FragmentList {
3203 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
3204 },
3205 );
3206 let mut protocol = WebUIProtocol::new(fragments);
3207 protocol
3208 .components
3209 .entry("app-shell".into())
3210 .or_default()
3211 .css_href = "/app-shell.css".into();
3212 protocol
3213 .components
3214 .entry("compose-page".into())
3215 .or_default()
3216 .template_json = r#"{"h":"<p>Compose</p>"}"#.into();
3217
3218 let chain = collect_route_chain(
3219 &protocol,
3220 "index.html",
3221 "/compose",
3222 &CompiledRouteIndex::new(&protocol),
3223 );
3224 assert_eq!(chain.len(), 2);
3225 assert_eq!(chain[0].component, "app-shell");
3226 assert!(chain[0].allowed_query.is_empty());
3227 assert_eq!(chain[1].component, "compose-page");
3228 assert_eq!(chain[1].allowed_query, "action,to");
3229 }
3230
3231 #[test]
3232 fn scriptless_partial_uses_navigation_keys_without_bootstrap_keys() {
3233 let mut fragments = HashMap::new();
3234 fragments.insert(
3235 "index.html".to_string(),
3236 FragmentList {
3237 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3238 path: "/items".into(),
3239 fragment_id: "items-page".into(),
3240 exact: true,
3241 ..Default::default()
3242 })],
3243 },
3244 );
3245 fragments.insert(
3246 "items-page".to_string(),
3247 FragmentList {
3248 fragments: vec![WebUIFragment::raw("<p>Items</p>")],
3249 },
3250 );
3251 let mut protocol = WebUIProtocol::new(fragments);
3252 protocol.components.insert(
3253 "items-page".to_string(),
3254 webui_protocol::ComponentData {
3255 template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
3256 navigation_mode: webui_protocol::StateProjectionMode::Keys as i32,
3257 navigation_keys: vec!["items".into(), "title".into()],
3258 ..Default::default()
3259 },
3260 );
3261
3262 let partial = render_partial(
3263 &protocol,
3264 serde_json::json!({
3265 "items": [1, 2],
3266 "serverOnly": "secret",
3267 "title": "Catalog",
3268 }),
3269 "index.html",
3270 "/items",
3271 "",
3272 )
3273 .unwrap();
3274
3275 assert_eq!(
3276 partial["state"],
3277 serde_json::json!({
3278 "items": [1, 2],
3279 "title": "Catalog",
3280 })
3281 );
3282 }
3283
3284 #[test]
3285 fn partial_requires_explicit_navigation_keys() {
3286 let mut fragments = HashMap::new();
3287 fragments.insert(
3288 "index.html".to_string(),
3289 FragmentList {
3290 fragments: vec![WebUIFragment::component("authored-card")],
3291 },
3292 );
3293 fragments.insert(
3294 "authored-card".to_string(),
3295 FragmentList {
3296 fragments: vec![WebUIFragment::raw("<p>Card</p>")],
3297 },
3298 );
3299 let mut protocol = WebUIProtocol::new(fragments);
3300 protocol.components.insert(
3301 "authored-card".to_string(),
3302 webui_protocol::ComponentData {
3303 hydration_mode: webui_protocol::StateProjectionMode::Keys as i32,
3304 hydration_keys: vec!["title".into()],
3305 ..Default::default()
3306 },
3307 );
3308
3309 let partial = render_partial(
3310 &protocol,
3311 serde_json::json!({
3312 "serverOnly": "secret",
3313 "title": "Authored",
3314 }),
3315 "index.html",
3316 "/",
3317 "",
3318 )
3319 .unwrap();
3320
3321 assert_eq!(partial["state"], serde_json::json!({}));
3322 }
3323
3324 #[test]
3325 fn fully_static_scriptless_partial_emits_empty_state() {
3326 let mut fragments = HashMap::new();
3327 fragments.insert(
3328 "index.html".to_string(),
3329 FragmentList {
3330 fragments: vec![WebUIFragment::component("static-card")],
3331 },
3332 );
3333 fragments.insert(
3334 "static-card".to_string(),
3335 FragmentList {
3336 fragments: vec![WebUIFragment::raw("<p>Static</p>")],
3337 },
3338 );
3339 let mut protocol = WebUIProtocol::new(fragments);
3340 protocol.components.insert(
3341 "static-card".to_string(),
3342 webui_protocol::ComponentData {
3343 template_json: r#"{"h":"<p>Static</p>","th":1}"#.into(),
3344 ..Default::default()
3345 },
3346 );
3347
3348 let partial = render_partial(
3349 &protocol,
3350 serde_json::json!({ "serverOnly": "secret" }),
3351 "index.html",
3352 "/",
3353 "",
3354 )
3355 .unwrap();
3356
3357 assert_eq!(partial["state"], serde_json::json!({}));
3358 }
3359
3360 #[test]
3361 fn test_render_component_templates_returns_template_and_css() {
3362 let mut fragments = HashMap::new();
3363 fragments.insert(
3364 "settings-dialog".to_string(),
3365 FragmentList {
3366 fragments: vec![WebUIFragment::raw("<div class='dialog'>Settings</div>")],
3367 },
3368 );
3369 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3370 let comp = protocol
3371 .components
3372 .entry("settings-dialog".to_string())
3373 .or_default();
3374 comp.template_json = r#"{"h":"<div>Settings</div>"}"#.to_string();
3375 comp.css = ".dialog{position:fixed}".to_string();
3376
3377 let mut index = ProtocolIndex::new(&protocol);
3378 let result =
3379 render_component_templates(&protocol, &["settings-dialog"], "", &mut index).unwrap();
3380 let templates = result["templates"].as_object().expect("templates object");
3381 let styles = result["templateStyles"].as_array().expect("styles array");
3382
3383 assert_eq!(templates.len(), 1);
3384 assert_eq!(templates["settings-dialog"]["h"], "<div>Settings</div>");
3385 assert_eq!(styles.len(), 1);
3386 let style_html = styles[0].as_str().unwrap();
3387 assert!(
3388 style_html.starts_with(r#"<script type="importmap""#)
3389 && style_html.contains(r#""settings-dialog":"data:text/css,"#),
3390 "templateStyles entry should be an importmap registering settings-dialog: {style_html}"
3391 );
3392 assert!(
3395 style_html.contains(".dialog{position:fixed}"),
3396 "templateStyles entry should contain the CSS content verbatim: {style_html}"
3397 );
3398 assert!(
3399 index
3400 .template_metadata_cache
3401 .contains_key("settings-dialog"),
3402 "parsed static template metadata should be cached in ProtocolIndex"
3403 );
3404 }
3405
3406 #[test]
3407 fn test_render_component_templates_respects_inventory() {
3408 let mut fragments = HashMap::new();
3409 fragments.insert(
3410 "my-dialog".to_string(),
3411 FragmentList {
3412 fragments: vec![WebUIFragment::raw("<div>Dialog</div>")],
3413 },
3414 );
3415 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3416 let comp = protocol
3417 .components
3418 .entry("my-dialog".to_string())
3419 .or_default();
3420 comp.template_json = r#"{"h":"<div>Dialog</div>"}"#.to_string();
3421 comp.css = ".d{color:red}".to_string();
3422
3423 let mut index = ProtocolIndex::new(&protocol);
3424 let result1 =
3426 render_component_templates(&protocol, &["my-dialog"], "", &mut index).unwrap();
3427 let inv = result1["inventory"].as_str().expect("inventory string");
3428 assert_eq!(result1["templates"].as_object().unwrap().len(), 1);
3429
3430 let result2 =
3432 render_component_templates(&protocol, &["my-dialog"], inv, &mut index).unwrap();
3433 assert_eq!(result2["templates"].as_object().unwrap().len(), 0);
3434 assert_eq!(result2["templateStyles"].as_array().unwrap().len(), 0);
3435 }
3436
3437 #[test]
3438 fn test_render_component_templates_unknown_component_returns_empty() {
3439 let fragments = HashMap::new();
3440 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3441
3442 let mut index = ProtocolIndex::new(&protocol);
3443 let result =
3444 render_component_templates(&protocol, &["nonexistent-widget"], "", &mut index).unwrap();
3445 assert_eq!(result["templates"].as_object().unwrap().len(), 0);
3446 assert_eq!(result["templateStyles"].as_array().unwrap().len(), 0);
3447 }
3448
3449 #[test]
3450 fn test_render_partial_includes_sibling_boundary_templates() {
3451 let mut fragments = HashMap::new();
3452 fragments.insert(
3453 "index.html".to_string(),
3454 FragmentList {
3455 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3456 path: "/".into(),
3457 fragment_id: "app-shell".into(),
3458 exact: false,
3459 children: vec![
3460 WebUiFragmentRoute {
3461 path: "inbox".into(),
3462 fragment_id: "inbox-page".into(),
3463 exact: true,
3464 pending_component: "inbox-loading".into(),
3465 ..Default::default()
3466 },
3467 WebUiFragmentRoute {
3468 path: "settings".into(),
3469 fragment_id: "settings-page".into(),
3470 exact: true,
3471 pending_component: "settings-loading".into(),
3472 error_component: "settings-error".into(),
3473 ..Default::default()
3474 },
3475 ],
3476 ..Default::default()
3477 })],
3478 },
3479 );
3480
3481 for name in [
3482 "app-shell",
3483 "inbox-page",
3484 "inbox-loading",
3485 "settings-page",
3486 "settings-loading",
3487 "settings-error",
3488 ] {
3489 fragments.insert(
3490 name.to_string(),
3491 FragmentList {
3492 fragments: vec![WebUIFragment::raw(format!("<p>{name}</p>"))],
3493 },
3494 );
3495 }
3496
3497 let mut protocol = WebUIProtocol::new(fragments);
3498 for name in [
3499 "app-shell",
3500 "inbox-page",
3501 "inbox-loading",
3502 "settings-page",
3503 "settings-loading",
3504 "settings-error",
3505 ] {
3506 protocol
3507 .components
3508 .entry(name.to_string())
3509 .or_default()
3510 .template_json = format!(r#"{{"h":"<p>{name}</p>"}}"#);
3511 }
3512
3513 let mut index = ProtocolIndex::new(&protocol);
3514 let partial =
3515 render_partial_metadata(&protocol, "index.html", "/inbox", "", &mut index).unwrap();
3516 let templates = partial["templates"].as_object().unwrap();
3517
3518 assert!(
3519 templates.contains_key("settings-loading"),
3520 "sibling pending template should be preloaded for route transitions: {partial}"
3521 );
3522 assert!(
3523 templates.contains_key("settings-error"),
3524 "sibling error template should be preloaded for failed route transitions: {partial}"
3525 );
3526 let chain = partial["chain"].as_array().unwrap();
3527 assert_eq!(chain.len(), 2);
3528 assert_eq!(chain[0]["component"], "app-shell");
3529 assert_eq!(chain[1]["component"], "inbox-page");
3530 }
3531
3532 #[test]
3535 fn test_resolve_tag_templates_no_placeholders() {
3536 let tags = vec!["inbox".to_string(), "counts".to_string()];
3537 let params = HashMap::new();
3538 let resolved = resolve_tag_templates(&tags, ¶ms);
3539 assert_eq!(resolved, vec!["inbox", "counts"]);
3540 }
3541
3542 #[test]
3543 fn test_resolve_tag_templates_with_params() {
3544 let tags = vec!["thread:{threadId}".to_string(), "inbox".to_string()];
3545 let mut params = HashMap::new();
3546 params.insert("threadId".to_string(), "42".to_string());
3547 let resolved = resolve_tag_templates(&tags, ¶ms);
3548 assert_eq!(resolved, vec!["thread:42", "inbox"]);
3549 }
3550
3551 #[test]
3552 fn test_resolve_tag_templates_multiple_params() {
3553 let tags = vec!["folder:{folderId}:user:{userId}".to_string()];
3554 let mut params = HashMap::new();
3555 params.insert("folderId".to_string(), "drafts".to_string());
3556 params.insert("userId".to_string(), "abc".to_string());
3557 let resolved = resolve_tag_templates(&tags, ¶ms);
3558 assert_eq!(resolved, vec!["folder:drafts:user:abc"]);
3559 }
3560
3561 #[test]
3562 fn test_resolve_tag_templates_missing_param_left_unresolved() {
3563 let tags = vec!["thread:{threadId}".to_string()];
3564 let params = HashMap::new();
3565 let resolved = resolve_tag_templates(&tags, ¶ms);
3566 assert_eq!(resolved, vec!["thread:{threadId}"]);
3567 }
3568
3569 #[test]
3572 fn test_chain_entry_to_json_includes_new_fields() {
3573 let entry = RouteChainEntry {
3574 component: "mail-thread".into(),
3575 path: "email/:threadId".into(),
3576 params: {
3577 let mut p = HashMap::new();
3578 p.insert("threadId".to_string(), "42".to_string());
3579 p
3580 },
3581 exact: true,
3582 allowed_query: String::new(),
3583 keep_alive: false,
3584 cache_tags: vec!["thread:42".to_string()],
3585 invalidates: vec!["inbox".to_string(), "counts".to_string()],
3586 pending_component: "mail-skeleton".into(),
3587 error_component: "error-page".into(),
3588 };
3589 let json = entry.to_json();
3590 assert_eq!(json["pendingComponent"], "mail-skeleton");
3591 assert_eq!(json["errorComponent"], "error-page");
3592 let inv = json["invalidates"].as_array().unwrap();
3593 assert_eq!(inv.len(), 2);
3594 assert_eq!(inv[0], "inbox");
3595 assert_eq!(inv[1], "counts");
3596 }
3597
3598 #[test]
3599 fn test_chain_entry_to_json_omits_empty_new_fields() {
3600 let entry = RouteChainEntry {
3601 component: "home-page".into(),
3602 path: "/".into(),
3603 params: HashMap::new(),
3604 exact: true,
3605 allowed_query: String::new(),
3606 keep_alive: false,
3607 cache_tags: Vec::new(),
3608 invalidates: Vec::new(),
3609 pending_component: String::new(),
3610 error_component: String::new(),
3611 };
3612 let json = entry.to_json();
3613 assert!(json.get("pendingComponent").is_none());
3614 assert!(json.get("errorComponent").is_none());
3615 assert!(json.get("invalidates").is_none());
3616 }
3617
3618 #[test]
3621 fn test_render_partial_includes_resolved_cache_tags() {
3622 let mut fragments = HashMap::new();
3623 fragments.insert(
3624 "index.html".to_string(),
3625 FragmentList {
3626 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3627 path: "/".into(),
3628 fragment_id: "app-shell".into(),
3629 exact: false,
3630 children: vec![WebUiFragmentRoute {
3631 path: "email/:threadId".into(),
3632 fragment_id: "mail-thread".into(),
3633 exact: true,
3634 cache_tags: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
3635 ..Default::default()
3636 }],
3637 cache_tags: vec!["folders".to_string()],
3638 ..Default::default()
3639 })],
3640 },
3641 );
3642 fragments.insert(
3643 "app-shell".to_string(),
3644 FragmentList {
3645 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
3646 },
3647 );
3648 fragments.insert(
3649 "mail-thread".to_string(),
3650 FragmentList {
3651 fragments: vec![WebUIFragment::raw("<p>Thread</p>")],
3652 },
3653 );
3654 let protocol = WebUIProtocol::new(fragments);
3655
3656 let mut index = ProtocolIndex::new(&protocol);
3657 let partial =
3658 render_partial_metadata(&protocol, "index.html", "/email/42", "", &mut index).unwrap();
3659 let tags = partial["cacheTags"].as_array().unwrap();
3660 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
3661 assert!(
3662 tag_strings.contains(&"folders"),
3663 "should contain parent tags"
3664 );
3665 assert!(
3666 tag_strings.contains(&"thread:42"),
3667 "should resolve threadId param to 42"
3668 );
3669 assert!(tag_strings.contains(&"inbox"), "should contain static tags");
3670 }
3671
3672 #[test]
3675 fn test_render_action_response_returns_resolved_invalidation_tags() {
3676 let mut fragments = HashMap::new();
3677 fragments.insert(
3678 "index.html".to_string(),
3679 FragmentList {
3680 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3681 path: "/".into(),
3682 fragment_id: "app-shell".into(),
3683 exact: false,
3684 children: vec![WebUiFragmentRoute {
3685 path: "compose".into(),
3686 fragment_id: "compose-page".into(),
3687 exact: true,
3688 invalidates: vec![
3689 "inbox".to_string(),
3690 "sent".to_string(),
3691 "counts".to_string(),
3692 ],
3693 ..Default::default()
3694 }],
3695 ..Default::default()
3696 })],
3697 },
3698 );
3699 fragments.insert(
3700 "app-shell".to_string(),
3701 FragmentList {
3702 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
3703 },
3704 );
3705 fragments.insert(
3706 "compose-page".to_string(),
3707 FragmentList {
3708 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
3709 },
3710 );
3711 let protocol = WebUIProtocol::new(fragments);
3712
3713 let mut index = ProtocolIndex::new(&protocol);
3714 let result = render_action_response(
3715 &protocol,
3716 serde_json::json!({"ok": true}),
3717 "index.html",
3718 "/compose",
3719 &mut index,
3720 );
3721
3722 let tags = result["invalidateTags"].as_array().unwrap();
3723 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
3724 assert_eq!(tag_strings, vec!["inbox", "sent", "counts"]);
3725 assert_eq!(result["state"]["ok"], true);
3726 }
3727
3728 #[test]
3729 fn test_render_action_response_resolves_param_placeholders() {
3730 let mut fragments = HashMap::new();
3731 fragments.insert(
3732 "index.html".to_string(),
3733 FragmentList {
3734 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3735 path: "/".into(),
3736 fragment_id: "app-shell".into(),
3737 exact: false,
3738 children: vec![WebUiFragmentRoute {
3739 path: "email/:threadId/reply".into(),
3740 fragment_id: "reply-page".into(),
3741 exact: true,
3742 invalidates: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
3743 ..Default::default()
3744 }],
3745 ..Default::default()
3746 })],
3747 },
3748 );
3749 fragments.insert(
3750 "app-shell".to_string(),
3751 FragmentList {
3752 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
3753 },
3754 );
3755 fragments.insert(
3756 "reply-page".to_string(),
3757 FragmentList {
3758 fragments: vec![WebUIFragment::raw("<p>Reply</p>")],
3759 },
3760 );
3761 let protocol = WebUIProtocol::new(fragments);
3762
3763 let mut index = ProtocolIndex::new(&protocol);
3764 let result = render_action_response(
3765 &protocol,
3766 serde_json::json!({}),
3767 "index.html",
3768 "/email/42/reply",
3769 &mut index,
3770 );
3771
3772 let tags = result["invalidateTags"].as_array().unwrap();
3773 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
3774 assert!(tag_strings.contains(&"thread:42"));
3775 assert!(tag_strings.contains(&"inbox"));
3776 }
3777}