1use crate::{route_matcher, route_renderer, HandlerError, StateSelection};
12use memchr::memchr2;
13use route_matcher::CompiledRouteIndex;
14use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor};
15use serde::ser::SerializeMap;
16use serde::Serialize;
17use serde_json::{value::RawValue, Map, Value};
18use std::borrow::Cow;
19use std::collections::{HashMap, HashSet};
20use std::fmt;
21use std::sync::{OnceLock, RwLock};
22use webui_protocol::{web_ui_fragment::Fragment, WebUIFragmentRoute, WebUIProtocol};
23
24pub struct Protocol {
36 protocol: WebUIProtocol,
37 component_index: HashMap<String, u32>,
38 component_reachability: OnceLock<ComponentReachabilityIndex>,
39 streaming_plans: HashMap<String, crate::streaming::PreparedStreamingEntryPlan>,
40 route_index: CompiledRouteIndex,
41 template_metadata_cache: RwLock<HashMap<String, Value>>,
42}
43
44impl Protocol {
45 pub fn from_protobuf(bytes: &[u8]) -> std::result::Result<Self, webui_protocol::ProtocolError> {
51 WebUIProtocol::from_protobuf(bytes).map(Self::new)
52 }
53
54 #[must_use]
56 pub fn new(protocol: WebUIProtocol) -> Self {
57 let component_index = build_component_index(&protocol);
58 let route_index = CompiledRouteIndex::new(&protocol);
59 let streaming_plans = protocol
60 .streaming_boundaries
61 .keys()
62 .filter_map(|entry| {
63 protocol.fragments.get(entry).map(|fragments| {
64 (
65 entry.clone(),
66 crate::streaming::PreparedStreamingEntryPlan::new(
67 entry,
68 &fragments.fragments,
69 protocol.streaming_boundaries.get(entry),
70 ),
71 )
72 })
73 })
74 .collect();
75 Self {
76 protocol,
77 component_index,
78 component_reachability: OnceLock::new(),
79 streaming_plans,
80 route_index,
81 template_metadata_cache: RwLock::new(HashMap::new()),
82 }
83 }
84
85 pub(crate) fn protocol(&self) -> &WebUIProtocol {
86 &self.protocol
87 }
88
89 pub(crate) fn component_index(&self) -> &HashMap<String, u32> {
90 &self.component_index
91 }
92
93 pub(crate) fn component_reachability(&self) -> &ComponentReachabilityIndex {
94 self.component_reachability
95 .get_or_init(|| ComponentReachabilityIndex::new(&self.protocol, &self.component_index))
96 }
97
98 pub(crate) fn streaming_plan(
99 &self,
100 entry_id: &str,
101 ) -> crate::Result<Option<&crate::streaming::StreamingEntryPlan>> {
102 self.streaming_plans
103 .get(entry_id)
104 .map_or(Ok(None), |plan| plan.resolve().map(Some))
105 }
106
107 pub(crate) fn streaming_boundary_names(&self, entry_id: &str) -> &[String] {
108 self.protocol
109 .streaming_boundaries
110 .get(entry_id)
111 .map_or(&[], |boundaries| boundaries.names.as_slice())
112 }
113
114 pub(crate) fn route_index(&self) -> &CompiledRouteIndex {
115 &self.route_index
116 }
117
118 #[must_use]
120 pub fn tokens(&self) -> &[String] {
121 &self.protocol.tokens
122 }
123
124 pub fn render_partial(
126 &self,
127 state_json: &str,
128 entry_id: &str,
129 request_path: &str,
130 inventory_hex: &str,
131 ) -> Result<String, HandlerError> {
132 let mut index = self.request_index();
133 let (response, state_selection) = render_partial_indexed_with_state(
134 self.protocol(),
135 entry_id,
136 request_path,
137 inventory_hex,
138 &mut index,
139 )?;
140 serialize_partial_response(&response, state_json, &state_selection)
141 }
142
143 pub fn render_component_templates(
145 &self,
146 component_tags: &[&str],
147 inventory_hex: &str,
148 ) -> Result<Value, HandlerError> {
149 let mut index = self.request_index();
150 render_component_templates_indexed(
151 self.protocol(),
152 component_tags,
153 inventory_hex,
154 &mut index,
155 )
156 }
157
158 #[cfg(test)]
159 fn render_partial_metadata(
160 &self,
161 entry_id: &str,
162 request_path: &str,
163 inventory_hex: &str,
164 ) -> Result<Value, HandlerError> {
165 let mut index = self.request_index();
166 render_partial_indexed(
167 self.protocol(),
168 entry_id,
169 request_path,
170 inventory_hex,
171 &mut index,
172 )
173 }
174
175 fn request_index(&self) -> RequestProtocolIndex<'_> {
176 RequestProtocolIndex {
177 component_index: &self.component_index,
178 route_index: &self.route_index,
179 template_metadata_cache: TemplateMetadataCache::Shared(&self.template_metadata_cache),
180 }
181 }
182}
183
184#[cfg(test)]
190struct ProtocolIndex {
191 pub component_index: HashMap<String, u32>,
193 pub route_index: CompiledRouteIndex,
195 template_metadata_cache: HashMap<String, Value>,
197}
198
199struct ComponentAssets {
200 styles: Vec<Value>,
201 templates: serde_json::Map<String, Value>,
202 functions: serde_json::Map<String, Value>,
203}
204
205#[cfg(test)]
206impl ProtocolIndex {
207 #[must_use]
209 pub(crate) fn new(protocol: &WebUIProtocol) -> Self {
210 Self {
211 component_index: build_component_index(protocol),
212 route_index: CompiledRouteIndex::new(protocol),
213 template_metadata_cache: HashMap::new(),
214 }
215 }
216
217 fn request_index(&mut self) -> RequestProtocolIndex<'_> {
218 RequestProtocolIndex {
219 component_index: &self.component_index,
220 route_index: &self.route_index,
221 template_metadata_cache: TemplateMetadataCache::Exclusive(
222 &mut self.template_metadata_cache,
223 ),
224 }
225 }
226}
227
228enum TemplateMetadataCache<'a> {
229 #[cfg(test)]
230 Exclusive(&'a mut HashMap<String, Value>),
231 Shared(&'a RwLock<HashMap<String, Value>>),
232}
233
234struct RequestProtocolIndex<'a> {
235 component_index: &'a HashMap<String, u32>,
236 route_index: &'a CompiledRouteIndex,
237 template_metadata_cache: TemplateMetadataCache<'a>,
238}
239
240pub(crate) fn build_component_index(protocol: &WebUIProtocol) -> HashMap<String, u32> {
249 let mut sorted: Vec<&String> = protocol
250 .fragments
251 .keys()
252 .filter(|key| key.contains('-'))
253 .collect();
254 sorted.sort_unstable();
255 let mut index = HashMap::with_capacity(sorted.len());
256 for (i, name) in sorted.into_iter().enumerate() {
257 #[allow(clippy::cast_possible_truncation)]
259 let idx = i as u32;
260 index.insert(name.clone(), idx);
261 }
262 index
263}
264
265pub(crate) struct ComponentReachabilityIndex {
270 names: Vec<String>,
271 dependencies: Vec<Box<[u32]>>,
272 route_dependent: Vec<bool>,
273}
274
275impl ComponentReachabilityIndex {
276 fn new(protocol: &WebUIProtocol, component_index: &HashMap<String, u32>) -> Self {
277 let mut names: Vec<String> = component_index.keys().cloned().collect();
278 names.sort_unstable();
279
280 let mut dependencies = Vec::with_capacity(names.len());
281 let mut route_dependent = Vec::with_capacity(names.len());
282 for name in &names {
283 let (direct, has_route) =
284 collect_direct_component_dependencies(protocol, name, component_index);
285 dependencies.push(direct.into_boxed_slice());
286 route_dependent.push(has_route);
287 }
288 propagate_route_dependencies(&dependencies, &mut route_dependent);
289
290 Self {
291 names,
292 dependencies,
293 route_dependent,
294 }
295 }
296
297 pub(crate) fn name(&self, index: u32) -> Option<&str> {
298 self.names.get(index as usize).map(String::as_str)
299 }
300
301 pub(crate) fn dependencies(&self, index: u32) -> Option<&[u32]> {
302 self.dependencies.get(index as usize).map(Box::as_ref)
303 }
304
305 pub(crate) fn is_route_dependent(&self, index: u32) -> Option<bool> {
306 self.route_dependent.get(index as usize).copied()
307 }
308
309 pub(crate) fn requires_expansion(&self, index: u32) -> Option<bool> {
310 Some(self.is_route_dependent(index)? || !self.dependencies.get(index as usize)?.is_empty())
311 }
312}
313
314enum ComponentDependencyWork<'a> {
315 Fragment(&'a str),
316 Component(u32),
317}
318
319fn collect_direct_component_dependencies(
320 protocol: &WebUIProtocol,
321 root: &str,
322 component_index: &HashMap<String, u32>,
323) -> (Vec<u32>, bool) {
324 let mut work = vec![ComponentDependencyWork::Fragment(root)];
325 let mut visited_fragments = HashSet::new();
326 let mut seen_components = HashSet::new();
327 let mut dependencies = Vec::new();
328 let mut has_route = false;
329
330 while let Some(item) = work.pop() {
331 match item {
332 ComponentDependencyWork::Component(index) => {
333 if seen_components.insert(index) {
334 dependencies.push(index);
335 }
336 }
337 ComponentDependencyWork::Fragment(id) => {
338 if !visited_fragments.insert(id) {
339 continue;
340 }
341 let Some(list) = protocol.fragments.get(id) else {
342 continue;
343 };
344 for fragment in list.fragments.iter().rev() {
345 match fragment.fragment.as_ref() {
346 Some(Fragment::Component(component)) => {
347 if let Some(&index) = component_index.get(&component.fragment_id) {
348 work.push(ComponentDependencyWork::Component(index));
349 }
350 }
351 Some(Fragment::ForLoop(for_loop)) => work.push(
352 ComponentDependencyWork::Fragment(for_loop.fragment_id.as_str()),
353 ),
354 Some(Fragment::IfCond(if_cond)) => work.push(
355 ComponentDependencyWork::Fragment(if_cond.fragment_id.as_str()),
356 ),
357 Some(Fragment::Attribute(attribute)) if !attribute.template.is_empty() => {
358 work.push(ComponentDependencyWork::Fragment(
359 attribute.template.as_str(),
360 ));
361 }
362 Some(Fragment::Route(_)) => has_route = true,
363 _ => {}
364 }
365 }
366 }
367 }
368 }
369
370 (dependencies, has_route)
371}
372
373fn propagate_route_dependencies(dependencies: &[Box<[u32]>], route_dependent: &mut [bool]) {
374 let mut reverse = vec![Vec::new(); dependencies.len()];
375 for (parent, children) in dependencies.iter().enumerate() {
376 for &child in children.iter() {
377 if let Some(parents) = reverse.get_mut(child as usize) {
378 #[allow(clippy::cast_possible_truncation)]
379 parents.push(parent as u32);
380 }
381 }
382 }
383
384 let mut pending = Vec::new();
385 for (index, &depends_on_route) in route_dependent.iter().enumerate() {
386 if depends_on_route {
387 #[allow(clippy::cast_possible_truncation)]
388 pending.push(index as u32);
389 }
390 }
391 while let Some(index) = pending.pop() {
392 let Some(parents) = reverse.get(index as usize) else {
393 continue;
394 };
395 for &parent in parents {
396 let Some(depends_on_route) = route_dependent.get_mut(parent as usize) else {
397 continue;
398 };
399 if !*depends_on_route {
400 *depends_on_route = true;
401 pending.push(parent);
402 }
403 }
404 }
405}
406
407fn has_component(inventory: &[u8], index: u32) -> bool {
409 let byte_idx = (index / 8) as usize;
410 let bit_idx = index % 8;
411 byte_idx < inventory.len() && inventory[byte_idx] & (1 << bit_idx) != 0
412}
413
414fn set_component(inventory: &mut Vec<u8>, index: u32) {
416 let byte_idx = (index / 8) as usize;
417 let bit_idx = index % 8;
418 if byte_idx >= inventory.len() {
419 inventory.resize(byte_idx + 1, 0);
420 }
421 inventory[byte_idx] |= 1 << bit_idx;
422}
423
424pub fn parse_inventory(hex: &str) -> Result<Vec<u8>, HandlerError> {
428 if hex.is_empty() {
429 return Ok(Vec::new());
430 }
431 if !hex.len().is_multiple_of(2) {
432 return Err(HandlerError::Protocol(
433 webui_protocol::ProtocolError::Validation(format!(
434 "inventory hex has odd length: {}",
435 hex.len()
436 )),
437 ));
438 }
439 let mut bytes = Vec::with_capacity(hex.len() / 2);
440 let mut chars = hex.bytes();
441 while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
442 let h = route_matcher::hex_val(hi).ok_or_else(|| {
443 HandlerError::Protocol(webui_protocol::ProtocolError::Validation(format!(
444 "invalid hex digit in inventory: {:#04x}",
445 hi
446 )))
447 })?;
448 let l = route_matcher::hex_val(lo).ok_or_else(|| {
449 HandlerError::Protocol(webui_protocol::ProtocolError::Validation(format!(
450 "invalid hex digit in inventory: {:#04x}",
451 lo
452 )))
453 })?;
454 bytes.push((h << 4) | l);
455 }
456 Ok(bytes)
457}
458
459pub fn encode_inventory(inv: &[u8]) -> String {
461 const HEX: &[u8; 16] = b"0123456789abcdef";
462 let mut encoded = String::with_capacity(inv.len() * 2);
463 for byte in inv {
464 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
465 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
466 }
467 encoded
468}
469
470pub(crate) fn encode_component_inventory(
472 component_names: &HashSet<String>,
473 index: &HashMap<String, u32>,
474) -> String {
475 let mut inventory = Vec::new();
476 for name in component_names {
477 if let Some(&idx) = index.get(name.as_str()) {
478 set_component(&mut inventory, idx);
479 }
480 }
481 encode_inventory(&inventory)
482}
483
484pub fn get_needed_components(
489 protocol: &WebUIProtocol,
490 entry_id: &str,
491 inventory_hex: &str,
492 component_index: &HashMap<String, u32>,
493) -> Result<(Vec<String>, String), HandlerError> {
494 let route_index = CompiledRouteIndex::new(protocol);
495 let component_names =
496 collect_inventoryable_components(protocol, entry_id, None, true, &route_index);
497 filter_needed_components(&component_names, inventory_hex, component_index)
498}
499
500pub(crate) fn get_needed_components_for_request(
505 protocol: &WebUIProtocol,
506 entry_id: &str,
507 request_path: &str,
508 inventory_hex: &str,
509 indexes: (&HashMap<String, u32>, &CompiledRouteIndex),
510) -> Result<(Vec<String>, String), HandlerError> {
511 let (component_index, route_index) = indexes;
512 let component_names = collect_inventoryable_components(
513 protocol,
514 entry_id,
515 Some(request_path),
516 false,
517 route_index,
518 );
519 filter_needed_components(&component_names, inventory_hex, component_index)
520}
521
522fn serialize_partial_response(
523 response: &Value,
524 state_json: &str,
525 state_selection: &StateSelection<'_>,
526) -> Result<String, HandlerError> {
527 let response = response
528 .as_object()
529 .ok_or_else(partial_response_not_object)?;
530 let state = select_raw_state(state_json, state_selection)?;
531 serde_json::to_string(&PartialResponseWithState { response, state })
532 .map_err(|error| partial_serialize_error(&error.to_string()))
533}
534
535fn validate_json(json: &str) -> Result<(), HandlerError> {
536 validate_json_inner(json).map_err(|error| invalid_state_json(&error.to_string()))
537}
538
539fn validate_json_inner(json: &str) -> serde_json::Result<()> {
540 let mut deserializer = serde_json::Deserializer::from_str(json);
541 ValidJson
542 .deserialize(&mut deserializer)
543 .and_then(|_| deserializer.end())
544}
545
546struct ValidJson;
547
548impl<'de> DeserializeSeed<'de> for ValidJson {
549 type Value = ();
550
551 fn deserialize<D>(self, deserializer: D) -> std::result::Result<(), D::Error>
552 where
553 D: serde::Deserializer<'de>,
554 {
555 deserializer.deserialize_any(ValidJsonVisitor)
556 }
557}
558
559struct ValidJsonVisitor;
560
561impl<'de> Visitor<'de> for ValidJsonVisitor {
562 type Value = ();
563
564 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
565 formatter.write_str("valid JSON")
566 }
567
568 fn visit_bool<E>(self, _value: bool) -> std::result::Result<(), E> {
569 Ok(())
570 }
571
572 fn visit_i64<E>(self, _value: i64) -> std::result::Result<(), E> {
573 Ok(())
574 }
575
576 fn visit_u64<E>(self, _value: u64) -> std::result::Result<(), E> {
577 Ok(())
578 }
579
580 fn visit_f64<E>(self, value: f64) -> std::result::Result<(), E>
581 where
582 E: serde::de::Error,
583 {
584 if value.is_finite() {
585 Ok(())
586 } else {
587 Err(E::custom("number out of range"))
588 }
589 }
590
591 fn visit_str<E>(self, _value: &str) -> std::result::Result<(), E> {
592 Ok(())
593 }
594
595 fn visit_borrowed_str<E>(self, _value: &'de str) -> std::result::Result<(), E> {
596 Ok(())
597 }
598
599 fn visit_string<E>(self, _value: String) -> std::result::Result<(), E> {
600 Ok(())
601 }
602
603 fn visit_unit<E>(self) -> std::result::Result<(), E> {
604 Ok(())
605 }
606
607 fn visit_none<E>(self) -> std::result::Result<(), E> {
608 Ok(())
609 }
610
611 fn visit_some<D>(self, deserializer: D) -> std::result::Result<(), D::Error>
612 where
613 D: serde::Deserializer<'de>,
614 {
615 ValidJson.deserialize(deserializer)
616 }
617
618 fn visit_seq<A>(self, mut sequence: A) -> std::result::Result<(), A::Error>
619 where
620 A: SeqAccess<'de>,
621 {
622 while sequence.next_element_seed(ValidJson)?.is_some() {}
623 Ok(())
624 }
625
626 fn visit_map<A>(self, mut map: A) -> std::result::Result<(), A::Error>
627 where
628 A: MapAccess<'de>,
629 {
630 while map.next_key::<IgnoredAny>()?.is_some() {
631 map.next_value_seed(ValidJson)?;
632 }
633 Ok(())
634 }
635}
636
637struct PartialResponseWithState<'a, 'state> {
638 response: &'a Map<String, Value>,
639 state: SelectedRawState<'state>,
640}
641
642impl Serialize for PartialResponseWithState<'_, '_> {
643 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
644 where
645 S: serde::Serializer,
646 {
647 let mut map = serializer.serialize_map(Some(self.response.len().saturating_add(1)))?;
648 for (key, value) in self.response {
649 map.serialize_entry(key, value)?;
650 }
651 map.serialize_entry("state", &self.state)?;
652 map.end()
653 }
654}
655
656struct ProjectedRawState<'de> {
657 entries: Vec<(Cow<'de, str>, &'de RawValue)>,
658}
659
660enum SelectedRawState<'de> {
661 Full(&'de RawValue),
662 Keys(ProjectedRawState<'de>),
663}
664impl Serialize for SelectedRawState<'_> {
665 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
666 where
667 S: serde::Serializer,
668 {
669 match self {
670 Self::Full(state) => state.serialize(serializer),
671 Self::Keys(state) => state.serialize(serializer),
672 }
673 }
674}
675
676impl Serialize for ProjectedRawState<'_> {
677 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
678 where
679 S: serde::Serializer,
680 {
681 let mut map = serializer.serialize_map(Some(self.entries.len()))?;
682 for (key, value) in &self.entries {
683 map.serialize_entry(key.as_ref(), value)?;
684 }
685 map.end()
686 }
687}
688
689fn project_raw_state<'de>(
690 state_json: &'de str,
691 state_keys: &[&str],
692) -> Result<ProjectedRawState<'de>, HandlerError> {
693 let is_object = state_json
694 .as_bytes()
695 .iter()
696 .find(|byte| !byte.is_ascii_whitespace())
697 .is_some_and(|byte| *byte == b'{');
698 if !is_object {
699 validate_json(state_json)?;
700 return Ok(ProjectedRawState {
701 entries: Vec::new(),
702 });
703 }
704
705 let mut deserializer = serde_json::Deserializer::from_str(state_json);
706 let mut state = ProjectedRawStateSeed { state_keys }
707 .deserialize(&mut deserializer)
708 .and_then(|state| {
709 deserializer.end()?;
710 Ok(state)
711 })
712 .map_err(|error| invalid_state_json(&error.to_string()))?;
713 state.entries.sort_unstable_by(|left, right| {
714 left.0.as_ref().as_bytes().cmp(right.0.as_ref().as_bytes())
715 });
716 Ok(state)
717}
718
719fn select_raw_state<'de>(
720 state_json: &'de str,
721 selection: &StateSelection<'_>,
722) -> Result<SelectedRawState<'de>, HandlerError> {
723 let state_keys = match selection {
724 StateSelection::Full => {
725 if contains_reserved_state_key(state_json) {
730 return strip_reserved_raw_state(state_json).map(SelectedRawState::Keys);
731 }
732 return serde_json::from_str::<&RawValue>(state_json)
733 .map(SelectedRawState::Full)
734 .map_err(|error| invalid_state_json(&error.to_string()));
735 }
736 StateSelection::Keys(keys) => keys.as_slice(),
737 StateSelection::BorrowedKeys(keys) => *keys,
738 };
739 project_raw_state(state_json, state_keys).map(SelectedRawState::Keys)
740}
741
742fn contains_reserved_state_key(state_json: &str) -> bool {
748 let bytes = state_json.as_bytes();
749 let mut search_start = 0;
750 while let Some(relative_index) = memchr2(b'$', b'\\', &bytes[search_start..]) {
751 let candidate = search_start + relative_index;
752 if candidate > 0
753 && bytes[candidate - 1] == b'"'
754 && matches_reserved_state_key(bytes, candidate)
755 {
756 return contains_top_level_reserved_state_key(bytes);
757 }
758 search_start = candidate + 1;
759 }
760 false
761}
762
763fn contains_top_level_reserved_state_key(bytes: &[u8]) -> bool {
764 let Some(mut cursor) = bytes.iter().position(|byte| !byte.is_ascii_whitespace()) else {
765 return false;
766 };
767 if bytes[cursor] != b'{' {
768 return false;
769 }
770
771 cursor += 1;
772 let mut depth = 1;
773 while cursor < bytes.len() {
774 match bytes[cursor] {
775 b'"' => {
776 if depth == 1 && matches_reserved_state_key(bytes, cursor + 1) {
777 return true;
778 }
779 cursor = skip_json_string(bytes, cursor + 1);
780 }
781 b'{' | b'[' => {
782 depth += 1;
783 cursor += 1;
784 }
785 b'}' | b']' => {
786 depth -= 1;
787 if depth == 0 {
788 return false;
789 }
790 cursor += 1;
791 }
792 _ => cursor += 1,
793 }
794 }
795 false
796}
797
798fn skip_json_string(bytes: &[u8], mut cursor: usize) -> usize {
799 while let Some(relative_index) = memchr2(b'"', b'\\', &bytes[cursor..]) {
800 cursor += relative_index;
801 if bytes[cursor] == b'"' {
802 return cursor + 1;
803 }
804 cursor = (cursor + 2).min(bytes.len());
805 }
806 bytes.len()
807}
808
809fn matches_reserved_state_key(bytes: &[u8], mut cursor: usize) -> bool {
810 for expected in crate::STATE_INJECT_KEY.bytes() {
811 if bytes.get(cursor).is_some_and(|byte| *byte == expected) {
812 cursor += 1;
813 } else if matches_unicode_escape(bytes, cursor, expected) {
814 cursor += 6;
815 } else {
816 return false;
817 }
818 }
819
820 if bytes.get(cursor) != Some(&b'"') {
821 return false;
822 }
823 cursor += 1;
824 while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
825 cursor += 1;
826 }
827 bytes.get(cursor) == Some(&b':')
828}
829
830fn matches_unicode_escape(bytes: &[u8], cursor: usize, expected: u8) -> bool {
831 let Some(escape) = bytes.get(cursor..cursor.saturating_add(6)) else {
832 return false;
833 };
834 escape[..4] == *b"\\u00"
835 && decode_hex_nibble(escape[4]).is_some_and(|high| {
836 decode_hex_nibble(escape[5]).is_some_and(|low| (high << 4) | low == expected)
837 })
838}
839
840fn decode_hex_nibble(byte: u8) -> Option<u8> {
841 match byte {
842 b'0'..=b'9' => Some(byte - b'0'),
843 b'a'..=b'f' => Some(byte - b'a' + 10),
844 b'A'..=b'F' => Some(byte - b'A' + 10),
845 _ => None,
846 }
847}
848
849fn strip_reserved_raw_state(state_json: &str) -> Result<ProjectedRawState<'_>, HandlerError> {
851 let mut deserializer = serde_json::Deserializer::from_str(state_json);
852 let projected = ReservedFilteredRawStateSeed
853 .deserialize(&mut deserializer)
854 .map_err(|error| invalid_state_json(&error.to_string()))?;
855 deserializer
856 .end()
857 .map_err(|error| invalid_state_json(&error.to_string()))?;
858 Ok(projected)
859}
860
861struct ReservedFilteredRawStateSeed;
862
863impl<'de> DeserializeSeed<'de> for ReservedFilteredRawStateSeed {
864 type Value = ProjectedRawState<'de>;
865
866 fn deserialize<D>(self, deserializer: D) -> std::result::Result<Self::Value, D::Error>
867 where
868 D: serde::Deserializer<'de>,
869 {
870 deserializer.deserialize_map(ReservedFilteredRawStateVisitor)
871 }
872}
873
874struct ReservedFilteredRawStateVisitor;
875
876impl<'de> Visitor<'de> for ReservedFilteredRawStateVisitor {
877 type Value = ProjectedRawState<'de>;
878
879 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
880 formatter.write_str("a JSON object")
881 }
882
883 fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
884 where
885 A: MapAccess<'de>,
886 {
887 let mut entries: Vec<(Cow<'de, str>, &'de RawValue)> = Vec::new();
888 while let Some(key) = map.next_key_seed(BorrowedString)? {
889 if key.as_ref() == crate::STATE_INJECT_KEY {
890 map.next_value_seed(ValidJson)?;
891 continue;
892 }
893 let value: &'de RawValue = map.next_value()?;
894 validate_json_inner(value.get()).map_err(serde::de::Error::custom)?;
895 entries.push((key, value));
896 }
897 Ok(ProjectedRawState { entries })
898 }
899}
900
901struct ProjectedRawStateSeed<'a> {
902 state_keys: &'a [&'a str],
903}
904
905impl<'de> DeserializeSeed<'de> for ProjectedRawStateSeed<'_> {
906 type Value = ProjectedRawState<'de>;
907
908 fn deserialize<D>(self, deserializer: D) -> std::result::Result<Self::Value, D::Error>
909 where
910 D: serde::Deserializer<'de>,
911 {
912 deserializer.deserialize_map(ProjectedRawStateVisitor {
913 state_keys: self.state_keys,
914 })
915 }
916}
917
918struct ProjectedRawStateVisitor<'a> {
919 state_keys: &'a [&'a str],
920}
921
922impl<'de> Visitor<'de> for ProjectedRawStateVisitor<'_> {
923 type Value = ProjectedRawState<'de>;
924
925 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
926 formatter.write_str("a JSON object")
927 }
928
929 fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
930 where
931 A: MapAccess<'de>,
932 {
933 let mut entries: Vec<(Cow<'de, str>, &'de RawValue)> =
934 Vec::with_capacity(self.state_keys.len());
935 while let Some(key) = map.next_key_seed(BorrowedString)? {
936 if key.as_ref() == crate::STATE_INJECT_KEY
937 || self.state_keys.binary_search(&key.as_ref()).is_err()
938 {
939 map.next_value_seed(ValidJson)?;
940 continue;
941 }
942
943 let value: &'de RawValue = map.next_value()?;
944 validate_json_inner(value.get()).map_err(serde::de::Error::custom)?;
945 if let Some(entry) = entries
946 .iter_mut()
947 .find(|(existing, _)| existing.as_ref() == key.as_ref())
948 {
949 *entry = (key, value);
950 } else {
951 entries.push((key, value));
952 }
953 }
954 Ok(ProjectedRawState { entries })
955 }
956}
957
958struct BorrowedString;
959
960impl<'de> DeserializeSeed<'de> for BorrowedString {
961 type Value = Cow<'de, str>;
962
963 fn deserialize<D>(self, deserializer: D) -> std::result::Result<Self::Value, D::Error>
964 where
965 D: serde::Deserializer<'de>,
966 {
967 deserializer.deserialize_str(BorrowedStringVisitor)
968 }
969}
970
971struct BorrowedStringVisitor;
972
973impl<'de> Visitor<'de> for BorrowedStringVisitor {
974 type Value = Cow<'de, str>;
975
976 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
977 formatter.write_str("a string")
978 }
979
980 fn visit_borrowed_str<E>(self, value: &'de str) -> std::result::Result<Self::Value, E> {
981 Ok(Cow::Borrowed(value))
982 }
983
984 fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E> {
985 Ok(Cow::Owned(value.to_owned()))
986 }
987
988 fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E> {
989 Ok(Cow::Owned(value))
990 }
991}
992
993#[cold]
994#[inline(never)]
995fn invalid_state_json(message: &str) -> HandlerError {
996 HandlerError::Rendering(format!("invalid state JSON: {message}"))
997}
998
999#[cold]
1000#[inline(never)]
1001fn partial_serialize_error(message: &str) -> HandlerError {
1002 HandlerError::Rendering(format!("failed to serialize partial response: {message}"))
1003}
1004
1005#[cold]
1006#[inline(never)]
1007fn partial_response_not_object() -> HandlerError {
1008 HandlerError::Invariant("partial response must serialize as a JSON object".to_string())
1009}
1010
1011pub(crate) fn collect_reachable_components_for_request(
1013 protocol: &WebUIProtocol,
1014 entry_id: &str,
1015 request_path: &str,
1016 route_index: &CompiledRouteIndex,
1017) -> HashSet<String> {
1018 collect_inventoryable_components(protocol, entry_id, Some(request_path), false, route_index)
1021 .into_iter()
1022 .collect()
1023}
1024
1025pub fn filter_needed_components(
1035 component_names: &[String],
1036 inventory_hex: &str,
1037 index: &HashMap<String, u32>,
1038) -> Result<(Vec<String>, String), HandlerError> {
1039 let client_inv = parse_inventory(inventory_hex)?;
1040 let mut updated_inv = client_inv.clone();
1041
1042 let mut needed = Vec::with_capacity(component_names.len());
1043 for name in component_names {
1044 if let Some(&idx) = index.get(name.as_str()) {
1045 if !has_component(&client_inv, idx) {
1046 needed.push(name.clone());
1047 }
1048 set_component(&mut updated_inv, idx);
1049 } else {
1050 needed.push(name.clone());
1054 }
1055 }
1056
1057 Ok((needed, encode_inventory(&updated_inv)))
1058}
1059
1060fn has_template_payload(component: &webui_protocol::ComponentData) -> bool {
1061 !component.template_json.is_empty() || !component.template.is_empty()
1062}
1063
1064#[derive(Debug)]
1065struct QueuedFragment {
1066 id: String,
1067 inventoryable: bool,
1068 route_base: String,
1070}
1071
1072#[inline]
1073fn mark_fragment_visited(
1074 visited: &mut HashSet<(String, String)>,
1075 queued: &QueuedFragment,
1076 route_sensitive: bool,
1077) -> bool {
1078 let route_base = if route_sensitive {
1079 queued.route_base.clone()
1080 } else {
1081 String::new()
1082 };
1083 visited.insert((queued.id.clone(), route_base))
1084}
1085
1086struct ChildWalkCtx<'a> {
1089 request_path: &'a str,
1090 protocol: &'a WebUIProtocol,
1091 route_index: &'a CompiledRouteIndex,
1092}
1093
1094fn collect_inventoryable_components(
1105 protocol: &WebUIProtocol,
1106 entry_id: &str,
1107 request_path: Option<&str>,
1108 root_inventoryable: bool,
1109 route_index: &CompiledRouteIndex,
1110) -> Vec<String> {
1111 let stack = vec![QueuedFragment {
1112 id: entry_id.to_string(),
1113 inventoryable: root_inventoryable,
1114 route_base: "/".to_string(),
1115 }];
1116 collect_inventoryable_components_from_stack(protocol, request_path, route_index, stack)
1117}
1118
1119pub(crate) fn collect_reachable_components_from_roots(
1127 protocol: &WebUIProtocol,
1128 roots: &[(u32, Option<Box<str>>)],
1129 reachability: &ComponentReachabilityIndex,
1130 request_path: &str,
1131 route_index: &CompiledRouteIndex,
1132) -> Vec<String> {
1133 let mut stack = Vec::with_capacity(roots.len());
1134 for (root, route_base) in roots.iter().rev() {
1135 let Some(name) = reachability.name(*root) else {
1136 continue;
1137 };
1138 stack.push(QueuedFragment {
1139 id: name.to_string(),
1140 inventoryable: true,
1141 route_base: route_base.as_deref().unwrap_or("/").to_string(),
1142 });
1143 }
1144 collect_inventoryable_components_from_stack(protocol, Some(request_path), route_index, stack)
1145}
1146
1147fn collect_inventoryable_components_from_stack(
1148 protocol: &WebUIProtocol,
1149 request_path: Option<&str>,
1150 route_index: &CompiledRouteIndex,
1151 mut stack: Vec<QueuedFragment>,
1152) -> Vec<String> {
1153 let mut visited_fragments = HashSet::new();
1154 let route_sensitive = request_path.is_some();
1155 let mut seen_components = HashSet::new();
1160 let mut component_ids: Vec<String> = Vec::new();
1161 while let Some(queued) = stack.pop() {
1162 if queued.id.is_empty() {
1163 continue;
1164 }
1165
1166 if queued.inventoryable && seen_components.insert(queued.id.clone()) {
1167 component_ids.push(queued.id.clone());
1168 }
1169
1170 if !mark_fragment_visited(&mut visited_fragments, &queued, route_sensitive) {
1171 continue;
1172 }
1173
1174 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1175 continue;
1176 };
1177
1178 let matched_route = request_path.and_then(|path| {
1179 route_renderer::find_best_route_match(
1180 &frag_list.fragments,
1181 path,
1182 &queued.route_base,
1183 route_index,
1184 )
1185 });
1186
1187 for frag in frag_list.fragments.iter().rev() {
1190 match frag.fragment.as_ref() {
1191 Some(Fragment::Component(component)) => {
1192 stack.push(QueuedFragment {
1193 id: component.fragment_id.clone(),
1194 inventoryable: true,
1195 route_base: queued.route_base.clone(),
1196 });
1197 }
1198 Some(Fragment::ForLoop(for_loop)) => {
1199 stack.push(QueuedFragment {
1200 id: for_loop.fragment_id.clone(),
1201 inventoryable: false,
1202 route_base: queued.route_base.clone(),
1203 });
1204 }
1205 Some(Fragment::IfCond(if_cond)) => {
1206 stack.push(QueuedFragment {
1207 id: if_cond.fragment_id.clone(),
1208 inventoryable: false,
1209 route_base: queued.route_base.clone(),
1210 });
1211 }
1212 Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
1213 stack.push(QueuedFragment {
1214 id: attr.template.clone(),
1215 inventoryable: false,
1216 route_base: queued.route_base.clone(),
1217 });
1218 }
1219 Some(Fragment::Route(route_frag)) => {
1220 let is_selected = matched_route
1221 .as_ref()
1222 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1223 if is_selected && !route_frag.fragment_id.is_empty() {
1224 let child_route_base = if let Some((_, ref rm)) = matched_route {
1226 if let Some(path) = request_path {
1227 route_matcher::compute_route_base(path, rm.consumed_segments)
1228 } else {
1229 queued.route_base.clone()
1230 }
1231 } else {
1232 queued.route_base.clone()
1233 };
1234
1235 stack.push(QueuedFragment {
1236 id: route_frag.fragment_id.clone(),
1237 inventoryable: protocol
1238 .components
1239 .get(&route_frag.fragment_id)
1240 .is_some_and(has_template_payload),
1241 route_base: child_route_base.clone(),
1242 });
1243
1244 collect_route_boundary_components(
1252 std::slice::from_ref(route_frag),
1253 &child_route_base,
1254 protocol,
1255 &mut stack,
1256 );
1257
1258 if !route_frag.children.is_empty() {
1262 if let Some(path) = request_path {
1263 walk_route_children(
1264 &route_frag.children,
1265 &child_route_base,
1266 &mut stack,
1267 &mut ChildWalkCtx {
1268 request_path: path,
1269 protocol,
1270 route_index,
1271 },
1272 );
1273 }
1274 }
1275 }
1276 }
1277 _ => {}
1278 }
1279 }
1280 }
1281
1282 component_ids
1283}
1284
1285fn select_best_child_route(
1290 children: &[WebUIFragmentRoute],
1291 request_path: &str,
1292 route_base: &str,
1293 route_index: &CompiledRouteIndex,
1294) -> Option<(usize, route_matcher::RouteMatch)> {
1295 let request_segments = route_matcher::split_request_path(request_path);
1296 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
1297 for (idx, child) in children.iter().enumerate() {
1298 if let Some(m) = route_matcher::match_route_indexed_with_segments(
1299 route_index,
1300 &child.path,
1301 route_base,
1302 &request_segments,
1303 child.exact,
1304 ) {
1305 let is_better = best
1306 .as_ref()
1307 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
1308 if is_better {
1309 best = Some((idx, m));
1310 }
1311 }
1312 }
1313 best
1314}
1315
1316fn walk_route_children(
1319 children: &[WebUIFragmentRoute],
1320 route_base: &str,
1321 stack: &mut Vec<QueuedFragment>,
1322 ctx: &mut ChildWalkCtx<'_>,
1323) {
1324 let mut current = children;
1325 let mut base = route_base.to_string();
1326
1327 loop {
1328 let Some((idx, ref rm)) =
1329 select_best_child_route(current, ctx.request_path, &base, ctx.route_index)
1330 else {
1331 break;
1332 };
1333 let matched = ¤t[idx];
1334 if matched.fragment_id.is_empty() {
1335 break;
1336 }
1337
1338 let child_base = route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
1339
1340 stack.push(QueuedFragment {
1341 id: matched.fragment_id.clone(),
1342 inventoryable: ctx
1343 .protocol
1344 .components
1345 .get(&matched.fragment_id)
1346 .is_some_and(has_template_payload),
1347 route_base: child_base.clone(),
1348 });
1349
1350 if !matched.pending_component.is_empty() {
1351 stack.push(QueuedFragment {
1352 id: matched.pending_component.clone(),
1353 inventoryable: ctx
1354 .protocol
1355 .components
1356 .get(&matched.pending_component)
1357 .is_some_and(has_template_payload),
1358 route_base: child_base.clone(),
1359 });
1360 }
1361 if !matched.error_component.is_empty() {
1362 stack.push(QueuedFragment {
1363 id: matched.error_component.clone(),
1364 inventoryable: ctx
1365 .protocol
1366 .components
1367 .get(&matched.error_component)
1368 .is_some_and(has_template_payload),
1369 route_base: child_base.clone(),
1370 });
1371 }
1372
1373 if matched.children.is_empty() {
1374 break;
1375 }
1376 current = &matched.children;
1377 base = child_base;
1378 }
1379}
1380
1381fn collect_route_boundary_components(
1390 routes: &[WebUIFragmentRoute],
1391 route_base: &str,
1392 protocol: &WebUIProtocol,
1393 stack: &mut Vec<QueuedFragment>,
1394) {
1395 let mut remaining: Vec<&WebUIFragmentRoute> = routes.iter().collect();
1396 while let Some(route) = remaining.pop() {
1397 for component in [&route.pending_component, &route.error_component] {
1398 if component.is_empty() {
1399 continue;
1400 }
1401 stack.push(QueuedFragment {
1402 id: component.clone(),
1403 inventoryable: protocol
1404 .components
1405 .get(component)
1406 .is_some_and(has_template_payload),
1407 route_base: route_base.to_string(),
1408 });
1409 }
1410 remaining.extend(route.children.iter());
1411 }
1412}
1413
1414pub fn collect_nested_route_params(
1419 protocol: &Protocol,
1420 entry_id: &str,
1421 request_path: &str,
1422) -> HashMap<String, String> {
1423 let route_index = protocol.route_index();
1424 let protocol = protocol.protocol();
1425 let mut all_params = HashMap::new();
1426 let mut visited_fragments = HashSet::new();
1427 let mut stack = vec![QueuedFragment {
1428 id: entry_id.to_string(),
1429 inventoryable: false,
1430 route_base: "/".to_string(),
1431 }];
1432
1433 while let Some(queued) = stack.pop() {
1434 if queued.id.is_empty() || !mark_fragment_visited(&mut visited_fragments, &queued, true) {
1435 continue;
1436 }
1437
1438 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1439 continue;
1440 };
1441
1442 let matched_route = route_renderer::find_best_route_match(
1443 &frag_list.fragments,
1444 request_path,
1445 &queued.route_base,
1446 route_index,
1447 );
1448
1449 for frag in &frag_list.fragments {
1450 match frag.fragment.as_ref() {
1451 Some(Fragment::Component(component)) => {
1452 stack.push(QueuedFragment {
1453 id: component.fragment_id.clone(),
1454 inventoryable: false,
1455 route_base: queued.route_base.clone(),
1456 });
1457 }
1458 Some(Fragment::Route(route_frag)) => {
1459 let is_selected = matched_route
1460 .as_ref()
1461 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1462 if is_selected && !route_frag.fragment_id.is_empty() {
1463 if let Some((_, ref rm)) = matched_route {
1464 all_params.extend(rm.params.clone());
1466
1467 let child_route_base = route_matcher::compute_route_base(
1468 request_path,
1469 rm.consumed_segments,
1470 );
1471
1472 stack.push(QueuedFragment {
1473 id: route_frag.fragment_id.clone(),
1474 inventoryable: false,
1475 route_base: child_route_base.clone(),
1476 });
1477
1478 collect_params_from_children(
1480 &route_frag.children,
1481 request_path,
1482 &child_route_base,
1483 &mut all_params,
1484 route_index,
1485 );
1486 }
1487 }
1488 }
1489 _ => {}
1490 }
1491 }
1492 }
1493
1494 all_params
1495}
1496
1497fn collect_params_from_children(
1499 children: &[WebUIFragmentRoute],
1500 request_path: &str,
1501 route_base: &str,
1502 all_params: &mut HashMap<String, String>,
1503 route_index: &CompiledRouteIndex,
1504) {
1505 let mut current = children;
1506 let mut base = route_base.to_string();
1507
1508 loop {
1509 let Some((idx, rm)) = select_best_child_route(current, request_path, &base, route_index)
1510 else {
1511 break;
1512 };
1513 all_params.extend(rm.params);
1514 let matched = ¤t[idx];
1515 if matched.children.is_empty() {
1516 break;
1517 }
1518 let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
1519 current = &matched.children;
1520 base = child_base;
1521 }
1522}
1523
1524fn resolve_tag_templates(templates: &[String], params: &HashMap<String, String>) -> Vec<String> {
1532 let mut resolved = Vec::with_capacity(templates.len());
1533 for template in templates {
1534 if !template.contains('{') {
1535 resolved.push(template.clone());
1536 continue;
1537 }
1538 let mut result = String::with_capacity(template.len());
1539 let mut rest = template.as_str();
1540 while let Some(open) = rest.find('{') {
1541 result.push_str(&rest[..open]);
1542 rest = &rest[open + 1..];
1543 if let Some(close) = rest.find('}') {
1544 let name = &rest[..close];
1545 if let Some(value) = params.get(name) {
1546 result.push_str(value);
1547 } else {
1548 result.push('{');
1549 result.push_str(name);
1550 result.push('}');
1551 }
1552 rest = &rest[close + 1..];
1553 } else {
1554 result.push('{');
1555 }
1556 }
1557 result.push_str(rest);
1558 resolved.push(result);
1559 }
1560 resolved
1561}
1562
1563fn collect_inventory_and_chain(
1567 protocol: &WebUIProtocol,
1568 entry_id: &str,
1569 request_path: &str,
1570 index: &mut RequestProtocolIndex<'_>,
1571) -> (Vec<String>, Vec<RouteChainEntry>) {
1572 let mut visited_fragments = HashSet::new();
1573 let mut seen_components = HashSet::new();
1578 let mut component_ids: Vec<String> = Vec::new();
1579 let mut chain = Vec::new();
1580 let mut stack = vec![QueuedFragment {
1581 id: entry_id.to_string(),
1582 inventoryable: false,
1583 route_base: "/".to_string(),
1584 }];
1585
1586 while let Some(queued) = stack.pop() {
1587 if queued.id.is_empty() {
1588 continue;
1589 }
1590
1591 if queued.inventoryable && seen_components.insert(queued.id.clone()) {
1592 component_ids.push(queued.id.clone());
1593 }
1594
1595 if !mark_fragment_visited(&mut visited_fragments, &queued, true) {
1596 continue;
1597 }
1598
1599 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1600 continue;
1601 };
1602
1603 let matched_route = route_renderer::find_best_route_match(
1604 &frag_list.fragments,
1605 request_path,
1606 &queued.route_base,
1607 index.route_index,
1608 );
1609
1610 for frag in &frag_list.fragments {
1611 match frag.fragment.as_ref() {
1612 Some(Fragment::Component(component)) => {
1613 stack.push(QueuedFragment {
1615 id: component.fragment_id.clone(),
1616 inventoryable: true,
1617 route_base: queued.route_base.clone(),
1618 });
1619 }
1620 Some(Fragment::ForLoop(for_loop)) => {
1621 stack.push(QueuedFragment {
1623 id: for_loop.fragment_id.clone(),
1624 inventoryable: false,
1625 route_base: queued.route_base.clone(),
1626 });
1627 }
1628 Some(Fragment::IfCond(if_cond)) => {
1629 stack.push(QueuedFragment {
1630 id: if_cond.fragment_id.clone(),
1631 inventoryable: false,
1632 route_base: queued.route_base.clone(),
1633 });
1634 }
1635 Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
1636 stack.push(QueuedFragment {
1637 id: attr.template.clone(),
1638 inventoryable: false,
1639 route_base: queued.route_base.clone(),
1640 });
1641 }
1642 Some(Fragment::Route(route_frag)) => {
1643 let is_selected = matched_route
1644 .as_ref()
1645 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1646 if is_selected && !route_frag.fragment_id.is_empty() {
1647 if let Some((_, ref rm)) = matched_route {
1648 chain.push(RouteChainEntry {
1650 component: route_frag.fragment_id.clone(),
1651 path: route_frag.path.clone(),
1652 params: rm.params.clone(),
1653 exact: route_frag.exact,
1654 allowed_query: route_frag.allowed_query.clone(),
1655 keep_alive: route_frag.keep_alive,
1656 cache_tags: route_frag.cache_tags.clone(),
1657 invalidates: route_frag.invalidates.clone(),
1658 pending_component: route_frag.pending_component.clone(),
1659 error_component: route_frag.error_component.clone(),
1660 });
1661
1662 let child_route_base = route_matcher::compute_route_base(
1663 request_path,
1664 rm.consumed_segments,
1665 );
1666
1667 let is_inventoryable = protocol
1669 .components
1670 .get(&route_frag.fragment_id)
1671 .is_some_and(has_template_payload);
1672 stack.push(QueuedFragment {
1673 id: route_frag.fragment_id.clone(),
1674 inventoryable: is_inventoryable,
1675 route_base: child_route_base.clone(),
1676 });
1677
1678 collect_route_boundary_components(
1679 std::slice::from_ref(route_frag),
1680 &child_route_base,
1681 protocol,
1682 &mut stack,
1683 );
1684
1685 if !route_frag.children.is_empty() {
1687 walk_children_for_inventory_and_chain(
1688 &route_frag.children,
1689 &child_route_base,
1690 &mut stack,
1691 &mut chain,
1692 &mut ChildWalkCtx {
1693 request_path,
1694 protocol,
1695 route_index: index.route_index,
1696 },
1697 );
1698 }
1699 }
1700 }
1701 }
1702 _ => {}
1703 }
1704 }
1705 }
1706
1707 (component_ids, chain)
1708}
1709
1710fn walk_children_for_inventory_and_chain(
1712 children: &[WebUIFragmentRoute],
1713 route_base: &str,
1714 stack: &mut Vec<QueuedFragment>,
1715 chain: &mut Vec<RouteChainEntry>,
1716 ctx: &mut ChildWalkCtx<'_>,
1717) {
1718 let mut current = children;
1719 let mut base = route_base.to_string();
1720
1721 loop {
1722 let Some((idx, ref rm)) =
1723 select_best_child_route(current, ctx.request_path, &base, ctx.route_index)
1724 else {
1725 break;
1726 };
1727 let matched = ¤t[idx];
1728 if matched.fragment_id.is_empty() {
1729 break;
1730 }
1731
1732 let child_base = route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
1733
1734 chain.push(RouteChainEntry {
1735 component: matched.fragment_id.clone(),
1736 path: matched.path.clone(),
1737 params: rm.params.clone(),
1738 exact: matched.exact,
1739 allowed_query: matched.allowed_query.clone(),
1740 keep_alive: matched.keep_alive,
1741 cache_tags: matched.cache_tags.clone(),
1742 invalidates: matched.invalidates.clone(),
1743 pending_component: matched.pending_component.clone(),
1744 error_component: matched.error_component.clone(),
1745 });
1746
1747 stack.push(QueuedFragment {
1748 id: matched.fragment_id.clone(),
1749 inventoryable: ctx
1750 .protocol
1751 .components
1752 .get(&matched.fragment_id)
1753 .is_some_and(has_template_payload),
1754 route_base: child_base.clone(),
1755 });
1756
1757 if matched.children.is_empty() {
1758 break;
1759 }
1760 current = &matched.children;
1761 base = child_base;
1762 }
1763}
1764
1765#[cfg(test)]
1780fn render_partial_metadata(
1781 protocol: &WebUIProtocol,
1782 entry_id: &str,
1783 request_path: &str,
1784 inventory_hex: &str,
1785 index: &mut ProtocolIndex,
1786) -> Result<Value, HandlerError> {
1787 let mut request_index = index.request_index();
1788 render_partial_indexed(
1789 protocol,
1790 entry_id,
1791 request_path,
1792 inventory_hex,
1793 &mut request_index,
1794 )
1795}
1796
1797#[cfg(test)]
1804fn render_partial(
1805 protocol: &WebUIProtocol,
1806 state: Value,
1807 entry_id: &str,
1808 request_path: &str,
1809 inventory_hex: &str,
1810) -> Result<Value, HandlerError> {
1811 let mut index = ProtocolIndex::new(protocol);
1812 let mut request_index = index.request_index();
1813 let (mut response, state_selection) = render_partial_indexed_with_state(
1814 protocol,
1815 entry_id,
1816 request_path,
1817 inventory_hex,
1818 &mut request_index,
1819 )?;
1820 let response = response
1821 .as_object_mut()
1822 .ok_or_else(partial_response_not_object)?;
1823 response.insert("state".into(), select_owned_state(state, &state_selection));
1824 Ok(Value::Object(std::mem::take(response)))
1825}
1826
1827#[cfg(test)]
1828fn render_partial_indexed(
1829 protocol: &WebUIProtocol,
1830 entry_id: &str,
1831 request_path: &str,
1832 inventory_hex: &str,
1833 index: &mut RequestProtocolIndex<'_>,
1834) -> Result<Value, HandlerError> {
1835 render_partial_indexed_with_state(protocol, entry_id, request_path, inventory_hex, index)
1836 .map(|(response, _)| response)
1837}
1838
1839fn render_partial_indexed_with_state<'a>(
1840 protocol: &'a WebUIProtocol,
1841 entry_id: &str,
1842 request_path: &str,
1843 inventory_hex: &str,
1844 index: &mut RequestProtocolIndex<'_>,
1845) -> Result<(Value, StateSelection<'a>), HandlerError> {
1846 let (component_ids, mut chain) =
1848 collect_inventory_and_chain(protocol, entry_id, request_path, index);
1849 let state_selection =
1850 crate::collect_navigation_state(protocol, component_ids.iter().map(String::as_str));
1851
1852 let (needed_names, updated_inv) =
1853 filter_needed_components(&component_ids, inventory_hex, index.component_index)?;
1854
1855 let mut accumulated_params: HashMap<String, String> = HashMap::new();
1857 let mut all_resolved_tags: Vec<String> = Vec::new();
1858
1859 for entry in &mut chain {
1860 for (k, v) in &entry.params {
1861 accumulated_params.insert(k.clone(), v.clone());
1862 }
1863 entry.cache_tags = resolve_tag_templates(&entry.cache_tags, &accumulated_params);
1864 entry.invalidates = resolve_tag_templates(&entry.invalidates, &accumulated_params);
1865 all_resolved_tags.extend(entry.cache_tags.iter().cloned());
1866 }
1867
1868 let tag_refs: Vec<&str> = needed_names.iter().map(|s| s.as_str()).collect();
1869 let assets = collect_component_assets(protocol, &tag_refs, index)?;
1870
1871 let chain_array = Value::Array(chain.iter().map(RouteChainEntry::to_json).collect());
1872
1873 let mut result = serde_json::Map::with_capacity(7);
1874 result.insert("templateStyles".into(), Value::Array(assets.styles));
1875 result.insert("templates".into(), Value::Object(assets.templates));
1876 result.insert("templateFunctions".into(), Value::Object(assets.functions));
1877 result.insert("inventory".into(), Value::String(updated_inv));
1878 result.insert("path".into(), Value::String(request_path.to_string()));
1879 result.insert("chain".into(), chain_array);
1880 if !all_resolved_tags.is_empty() {
1881 let mut seen = HashSet::new();
1882 let deduped: Vec<Value> = all_resolved_tags
1883 .into_iter()
1884 .filter(|t| seen.insert(t.clone()))
1885 .map(Value::String)
1886 .collect();
1887 result.insert("cacheTags".into(), Value::Array(deduped));
1888 }
1889 Ok((Value::Object(result), state_selection))
1890}
1891
1892#[cfg(test)]
1893fn select_owned_state(state: Value, selection: &StateSelection<'_>) -> Value {
1894 let state_keys = match selection {
1895 StateSelection::Full => return state,
1896 StateSelection::Keys(keys) => keys.as_slice(),
1897 StateSelection::BorrowedKeys(keys) => *keys,
1898 };
1899 let Value::Object(mut source) = state else {
1900 return Value::Object(Map::new());
1901 };
1902 let mut projected = Map::with_capacity(state_keys.len().min(source.len()));
1903 for &key in state_keys {
1904 if let Some(value) = source.remove(key) {
1905 projected.insert(key.to_owned(), value);
1906 }
1907 }
1908 Value::Object(projected)
1909}
1910
1911#[cfg(test)]
1922fn render_action_response(
1923 protocol: &WebUIProtocol,
1924 state: Value,
1925 entry_id: &str,
1926 request_path: &str,
1927 index: &mut ProtocolIndex,
1928) -> Value {
1929 let mut request_index = index.request_index();
1930 render_action_response_indexed(protocol, state, entry_id, request_path, &mut request_index)
1931}
1932
1933#[cfg(test)]
1934fn render_action_response_indexed(
1935 protocol: &WebUIProtocol,
1936 state: Value,
1937 entry_id: &str,
1938 request_path: &str,
1939 index: &mut RequestProtocolIndex<'_>,
1940) -> Value {
1941 let chain = collect_route_chain(protocol, entry_id, request_path, index.route_index);
1942
1943 let mut accumulated_params: HashMap<String, String> = HashMap::new();
1945 let mut all_invalidates: Vec<String> = Vec::new();
1946
1947 for entry in &chain {
1948 for (k, v) in &entry.params {
1949 accumulated_params.insert(k.clone(), v.clone());
1950 }
1951 let resolved = resolve_tag_templates(&entry.invalidates, &accumulated_params);
1952 all_invalidates.extend(resolved);
1953 }
1954
1955 let mut seen = HashSet::new();
1957 let deduped: Vec<Value> = all_invalidates
1958 .into_iter()
1959 .filter(|t| seen.insert(t.clone()))
1960 .map(Value::String)
1961 .collect();
1962
1963 let mut result = serde_json::Map::with_capacity(3);
1964 result.insert("state".into(), state);
1965 result.insert("invalidateTags".into(), Value::Array(deduped));
1966 result.insert("path".into(), Value::String(request_path.to_string()));
1967 Value::Object(result)
1968}
1969
1970#[cfg(test)]
1979fn render_component_templates(
1980 protocol: &WebUIProtocol,
1981 component_tags: &[&str],
1982 inventory_hex: &str,
1983 index: &mut ProtocolIndex,
1984) -> Result<Value, HandlerError> {
1985 let mut request_index = index.request_index();
1986 render_component_templates_indexed(protocol, component_tags, inventory_hex, &mut request_index)
1987}
1988
1989fn render_component_templates_indexed(
1990 protocol: &WebUIProtocol,
1991 component_tags: &[&str],
1992 inventory_hex: &str,
1993 index: &mut RequestProtocolIndex<'_>,
1994) -> Result<Value, HandlerError> {
1995 let mut seen: HashSet<&str> = HashSet::new();
1997 let requested: Vec<String> = component_tags
1998 .iter()
1999 .filter(|s| seen.insert(**s))
2000 .map(|s| (*s).to_string())
2001 .collect();
2002 let (needed, updated_inv) =
2003 filter_needed_components(&requested, inventory_hex, index.component_index)?;
2004
2005 let tag_refs: Vec<&str> = needed.iter().map(|s| s.as_str()).collect();
2006 let assets = collect_component_assets(protocol, &tag_refs, index)?;
2007
2008 let mut result = serde_json::Map::with_capacity(4);
2009 result.insert("templateStyles".into(), Value::Array(assets.styles));
2010 result.insert("templates".into(), Value::Object(assets.templates));
2011 result.insert("templateFunctions".into(), Value::Object(assets.functions));
2012 result.insert("inventory".into(), Value::String(updated_inv));
2013 Ok(Value::Object(result))
2014}
2015
2016fn collect_component_assets(
2018 protocol: &WebUIProtocol,
2019 tags: &[&str],
2020 index: &mut RequestProtocolIndex<'_>,
2021) -> Result<ComponentAssets, HandlerError> {
2022 let mut style_array = Vec::new();
2023 let mut tmpl_map = serde_json::Map::new();
2024 let mut function_map = serde_json::Map::new();
2025
2026 let mut sorted_tags: Vec<&str> = tags.to_vec();
2028 sorted_tags.sort_unstable();
2029
2030 for tag in sorted_tags {
2031 let Some(component) = protocol.components.get(tag) else {
2032 continue;
2033 };
2034 if !has_template_payload(component) {
2035 continue;
2036 }
2037 if !component.css.is_empty() {
2038 let tag_html = crate::css_module::build_importmap_tag(tag, &component.css, None);
2042 style_array.push(Value::String(tag_html));
2043 }
2044 if !component.template_json.is_empty() {
2045 let template_value = cached_template_metadata(
2046 &mut index.template_metadata_cache,
2047 tag,
2048 &component.template_json,
2049 )?;
2050 tmpl_map.insert(tag.to_string(), template_value);
2051 if !component.template_functions.is_empty() {
2052 function_map.insert(
2053 tag.to_string(),
2054 Value::String(component.template_functions.clone()),
2055 );
2056 }
2057 } else {
2058 tmpl_map.insert(tag.to_string(), Value::String(component.template.clone()));
2059 }
2060 }
2061
2062 Ok(ComponentAssets {
2063 styles: style_array,
2064 templates: tmpl_map,
2065 functions: function_map,
2066 })
2067}
2068
2069fn cached_template_metadata(
2070 cache: &mut TemplateMetadataCache<'_>,
2071 tag: &str,
2072 template_json: &str,
2073) -> Result<Value, HandlerError> {
2074 match cache {
2075 #[cfg(test)]
2076 TemplateMetadataCache::Exclusive(cache) => {
2077 cached_template_metadata_exclusive(cache, tag, template_json)
2078 }
2079 TemplateMetadataCache::Shared(cache) => {
2080 if let Some(value) = cache
2081 .read()
2082 .map_err(|_| protocol_metadata_cache_poisoned())?
2083 .get(tag)
2084 .cloned()
2085 {
2086 return Ok(value);
2087 }
2088
2089 let value = parse_template_metadata(tag, template_json)?;
2090 let mut cache = cache
2091 .write()
2092 .map_err(|_| protocol_metadata_cache_poisoned())?;
2093 Ok(cache.entry(tag.to_string()).or_insert(value).clone())
2094 }
2095 }
2096}
2097
2098#[cfg(test)]
2099fn cached_template_metadata_exclusive(
2100 cache: &mut HashMap<String, Value>,
2101 tag: &str,
2102 template_json: &str,
2103) -> Result<Value, HandlerError> {
2104 if let Some(value) = cache.get(tag) {
2105 return Ok(value.clone());
2106 }
2107
2108 let value = parse_template_metadata(tag, template_json)?;
2109 cache.insert(tag.to_string(), value.clone());
2110 Ok(value)
2111}
2112
2113fn parse_template_metadata(tag: &str, template_json: &str) -> Result<Value, HandlerError> {
2114 serde_json::from_str(template_json).map_err(|error| {
2115 HandlerError::Rendering(format!(
2116 "failed to parse template metadata for {tag}: {error}"
2117 ))
2118 })
2119}
2120
2121#[cold]
2122#[inline(never)]
2123fn protocol_metadata_cache_poisoned() -> HandlerError {
2124 HandlerError::Invariant(
2125 "protocol metadata cache is unavailable after a previous panic".to_string(),
2126 )
2127}
2128
2129#[derive(Debug, Clone)]
2133pub struct RouteChainEntry {
2134 pub component: String,
2136 pub path: String,
2138 pub params: HashMap<String, String>,
2140 pub exact: bool,
2142 pub allowed_query: String,
2144 pub keep_alive: bool,
2146 pub cache_tags: Vec<String>,
2148 pub invalidates: Vec<String>,
2150 pub pending_component: String,
2152 pub error_component: String,
2154}
2155
2156impl RouteChainEntry {
2157 #[must_use]
2159 pub fn to_json(&self) -> Value {
2160 let mut obj = serde_json::Map::with_capacity(9);
2161 obj.insert("component".into(), Value::String(self.component.clone()));
2162 obj.insert("path".into(), Value::String(self.path.clone()));
2163 if !self.params.is_empty() {
2164 let params_obj: serde_json::Map<String, Value> = self
2165 .params
2166 .iter()
2167 .map(|(k, v)| (k.clone(), Value::String(v.clone())))
2168 .collect();
2169 obj.insert("params".into(), Value::Object(params_obj));
2170 }
2171 if self.exact {
2172 obj.insert("exact".into(), Value::Bool(true));
2173 }
2174 if !self.allowed_query.is_empty() {
2175 obj.insert(
2176 "allowedQuery".into(),
2177 Value::String(self.allowed_query.clone()),
2178 );
2179 }
2180 if self.keep_alive {
2181 obj.insert("keepAlive".into(), Value::Bool(true));
2182 }
2183 if !self.pending_component.is_empty() {
2184 obj.insert(
2185 "pendingComponent".into(),
2186 Value::String(self.pending_component.clone()),
2187 );
2188 }
2189 if !self.error_component.is_empty() {
2190 obj.insert(
2191 "errorComponent".into(),
2192 Value::String(self.error_component.clone()),
2193 );
2194 }
2195 if !self.invalidates.is_empty() {
2196 obj.insert(
2197 "invalidates".into(),
2198 Value::Array(
2199 self.invalidates
2200 .iter()
2201 .map(|s| Value::String(s.clone()))
2202 .collect(),
2203 ),
2204 );
2205 }
2206 Value::Object(obj)
2207 }
2208}
2209
2210pub(crate) fn collect_route_chain(
2215 protocol: &WebUIProtocol,
2216 entry_id: &str,
2217 request_path: &str,
2218 route_index: &CompiledRouteIndex,
2219) -> Vec<RouteChainEntry> {
2220 let mut chain = Vec::new();
2221 let mut visited_fragments = HashSet::new();
2222 let mut stack = vec![QueuedFragment {
2223 id: entry_id.to_string(),
2224 inventoryable: false,
2225 route_base: "/".to_string(),
2226 }];
2227
2228 while let Some(queued) = stack.pop() {
2229 if queued.id.is_empty() || !mark_fragment_visited(&mut visited_fragments, &queued, true) {
2230 continue;
2231 }
2232
2233 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
2234 continue;
2235 };
2236
2237 let matched_route = route_renderer::find_best_route_match(
2238 &frag_list.fragments,
2239 request_path,
2240 &queued.route_base,
2241 route_index,
2242 );
2243
2244 for frag in &frag_list.fragments {
2245 match frag.fragment.as_ref() {
2246 Some(Fragment::Component(component)) => {
2247 stack.push(QueuedFragment {
2248 id: component.fragment_id.clone(),
2249 inventoryable: false,
2250 route_base: queued.route_base.clone(),
2251 });
2252 }
2253 Some(Fragment::Route(route_frag)) => {
2254 let is_selected = matched_route
2255 .as_ref()
2256 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
2257 if is_selected && !route_frag.fragment_id.is_empty() {
2258 if let Some((_, ref rm)) = matched_route {
2259 chain.push(RouteChainEntry {
2260 component: route_frag.fragment_id.clone(),
2261 path: route_frag.path.clone(),
2262 params: rm.params.clone(),
2263 exact: route_frag.exact,
2264 allowed_query: route_frag.allowed_query.clone(),
2265 keep_alive: route_frag.keep_alive,
2266 cache_tags: route_frag.cache_tags.clone(),
2267 invalidates: route_frag.invalidates.clone(),
2268 pending_component: route_frag.pending_component.clone(),
2269 error_component: route_frag.error_component.clone(),
2270 });
2271
2272 let child_route_base = route_matcher::compute_route_base(
2273 request_path,
2274 rm.consumed_segments,
2275 );
2276
2277 stack.push(QueuedFragment {
2278 id: route_frag.fragment_id.clone(),
2279 inventoryable: false,
2280 route_base: child_route_base.clone(),
2281 });
2282
2283 collect_chain_from_children(
2285 &route_frag.children,
2286 &child_route_base,
2287 &mut chain,
2288 &mut ChildWalkCtx {
2289 request_path,
2290 protocol,
2291 route_index,
2292 },
2293 );
2294 }
2295 }
2296 }
2297 _ => {}
2298 }
2299 }
2300 }
2301
2302 chain
2303}
2304
2305fn collect_chain_from_children(
2307 children: &[WebUIFragmentRoute],
2308 route_base: &str,
2309 chain: &mut Vec<RouteChainEntry>,
2310 ctx: &mut ChildWalkCtx<'_>,
2311) {
2312 let mut pending: Vec<(&[WebUIFragmentRoute], String)> =
2313 vec![(children, route_base.to_string())];
2314
2315 while let Some((current, base)) = pending.pop() {
2316 if let Some((idx, rm)) =
2317 select_best_child_route(current, ctx.request_path, &base, ctx.route_index)
2318 {
2319 let matched = ¤t[idx];
2320 chain.push(RouteChainEntry {
2321 component: matched.fragment_id.clone(),
2322 path: matched.path.clone(),
2323 params: rm.params,
2324 exact: matched.exact,
2325 allowed_query: matched.allowed_query.clone(),
2326 keep_alive: matched.keep_alive,
2327 cache_tags: matched.cache_tags.clone(),
2328 invalidates: matched.invalidates.clone(),
2329 pending_component: matched.pending_component.clone(),
2330 error_component: matched.error_component.clone(),
2331 });
2332 if !matched.children.is_empty() {
2333 let child_base =
2334 route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
2335 pending.push((&matched.children, child_base));
2336 }
2337 }
2338 }
2339}
2340
2341#[cfg(test)]
2342mod tests {
2343 use super::*;
2344 use std::collections::HashMap;
2345 use std::sync::{Arc, Barrier};
2346 use std::thread;
2347 use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
2348
2349 #[test]
2350 fn protocol_decodes_once_and_exposes_tokens() {
2351 let mut fragments = HashMap::new();
2352 fragments.insert(
2353 "index.html".to_string(),
2354 FragmentList {
2355 fragments: vec![WebUIFragment::raw("<p>Prepared</p>")],
2356 },
2357 );
2358 let protocol = WebUIProtocol::with_tokens(fragments, vec!["colorBrand".to_string()]);
2359 let bytes = protocol.to_protobuf().unwrap();
2360 let prepared = Protocol::from_protobuf(&bytes).unwrap();
2361
2362 assert!(prepared.protocol().fragments.contains_key("index.html"));
2363 assert_eq!(prepared.tokens(), ["colorBrand"]);
2364 }
2365
2366 #[test]
2367 fn protocol_partial_matches_direct_index_path() {
2368 let mut fragments = HashMap::new();
2369 fragments.insert(
2370 "index.html".to_string(),
2371 FragmentList {
2372 fragments: vec![WebUIFragment::route("/", "home-page")],
2373 },
2374 );
2375 fragments.insert(
2376 "home-page".to_string(),
2377 FragmentList {
2378 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
2379 },
2380 );
2381 let protocol = WebUIProtocol::new(fragments);
2382 let mut direct_index = ProtocolIndex::new(&protocol);
2383 let direct =
2384 render_partial_metadata(&protocol, "index.html", "/", "", &mut direct_index).unwrap();
2385 let prepared = Protocol::new(protocol);
2386 let cached = prepared
2387 .render_partial_metadata("index.html", "/", "")
2388 .unwrap();
2389
2390 assert_eq!(cached, direct);
2391 }
2392
2393 #[test]
2394 fn protocol_partial_supports_concurrent_metadata_reads() {
2395 let mut fragments = HashMap::new();
2396 fragments.insert(
2397 "index.html".to_string(),
2398 FragmentList {
2399 fragments: vec![WebUIFragment::route("/", "home-page")],
2400 },
2401 );
2402 fragments.insert(
2403 "home-page".to_string(),
2404 FragmentList {
2405 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
2406 },
2407 );
2408 let mut protocol = WebUIProtocol::new(fragments);
2409 protocol.components.insert(
2410 "home-page".to_string(),
2411 webui_protocol::ComponentData {
2412 template_json: r#"{"h":"<p>Home</p>"}"#.to_string(),
2413 ..Default::default()
2414 },
2415 );
2416
2417 let prepared = Arc::new(Protocol::new(protocol));
2418 let barrier = Arc::new(Barrier::new(4));
2419 let handles: Vec<_> = (0..4)
2420 .map(|_| {
2421 let prepared = Arc::clone(&prepared);
2422 let barrier = Arc::clone(&barrier);
2423 thread::spawn(move || {
2424 barrier.wait();
2425 for _ in 0..50 {
2426 let response = prepared
2427 .render_partial_metadata("index.html", "/", "")
2428 .unwrap();
2429 assert_eq!(response["templates"]["home-page"]["h"], "<p>Home</p>");
2430 }
2431 })
2432 })
2433 .collect();
2434
2435 for handle in handles {
2436 handle.join().unwrap();
2437 }
2438 }
2439
2440 fn prepared_partial_protocol(hydration_keys: &[&str]) -> Protocol {
2441 let mut fragments = HashMap::new();
2442 fragments.insert(
2443 "index.html".to_string(),
2444 FragmentList {
2445 fragments: vec![WebUIFragment::route("/", "home-page")],
2446 },
2447 );
2448 fragments.insert(
2449 "home-page".to_string(),
2450 FragmentList {
2451 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
2452 },
2453 );
2454 let mut protocol = WebUIProtocol::new(fragments);
2455 protocol.components.insert(
2456 "home-page".to_string(),
2457 webui_protocol::ComponentData {
2458 template_json: r#"{"h":"<p>Home</p>"}"#.to_string(),
2459 hydration_mode: webui_protocol::StateProjectionMode::Keys as i32,
2460 hydration_keys: hydration_keys
2461 .iter()
2462 .map(|key| (*key).to_string())
2463 .collect(),
2464 navigation_mode: Some(webui_protocol::StateProjectionMode::Keys as i32),
2465 navigation_keys: hydration_keys
2466 .iter()
2467 .map(|key| (*key).to_string())
2468 .collect(),
2469 ..Default::default()
2470 },
2471 );
2472 Protocol::new(protocol)
2473 }
2474
2475 fn prepared_full_state_partial_protocol() -> Protocol {
2476 let mut prepared = prepared_partial_protocol(&[]);
2477 let protocol = &mut prepared.protocol;
2478 if let Some(component) = protocol.components.get_mut("home-page") {
2479 component.navigation_mode = Some(webui_protocol::StateProjectionMode::All as i32);
2480 component.navigation_keys.clear();
2481 }
2482 prepared
2483 }
2484
2485 #[test]
2486 fn partial_state_serialization_preserves_validated_raw_json() {
2487 let prepared = prepared_partial_protocol(&["value"]);
2488 let output = prepared
2489 .render_partial(
2490 r#"{"serverOnly":"drop","value":1e2}"#,
2491 "index.html",
2492 "/",
2493 "",
2494 )
2495 .unwrap();
2496 assert!(output.contains(r#""state":{"value":1e2}"#));
2497 assert!(!output.contains("serverOnly"));
2498
2499 let parsed: Value = serde_json::from_str(&output).unwrap();
2500 assert_eq!(parsed["state"]["value"], 100.0);
2501 }
2502
2503 #[test]
2504 fn partial_state_projection_strips_reserved_inject_key() {
2505 let prepared = prepared_partial_protocol(&[crate::STATE_INJECT_KEY, "value"]);
2506 let output = prepared
2507 .render_partial(
2508 r#"{"$webui":{"bodyEnd":"<script>secret</script>"},"serverOnly":"drop","value":1}"#,
2509 "index.html",
2510 "/",
2511 "",
2512 )
2513 .unwrap();
2514 let parsed: Value = serde_json::from_str(&output).unwrap();
2515
2516 assert_eq!(parsed["state"], serde_json::json!({"value": 1}));
2517 assert!(!output.contains("<script>secret</script>"), "{output}");
2518 }
2519
2520 #[test]
2521 fn uncertain_partial_surface_preserves_complete_raw_state() {
2522 let prepared = prepared_full_state_partial_protocol();
2523 let output = prepared
2524 .render_partial(
2525 r#"{"serverOnly":"keep","value":1e2}"#,
2526 "index.html",
2527 "/",
2528 "",
2529 )
2530 .unwrap();
2531 assert!(output.contains(r#""state":{"serverOnly":"keep","value":1e2}"#));
2532 }
2533
2534 #[test]
2535 fn uncertain_partial_surface_strips_escaped_reserved_inject_key() {
2536 let prepared = prepared_full_state_partial_protocol();
2537 for state in [
2538 r#"{"\u0024webui":{"bodyEnd":"<script>secret</script>"},"serverOnly":"keep","value":1}"#,
2539 r#"{"$\u0077\u0065\u0062\u0075\u0069":{"bodyEnd":"<script>secret</script>"},"serverOnly":"keep","value":1}"#,
2540 ] {
2541 let output = prepared
2542 .render_partial(state, "index.html", "/", "")
2543 .unwrap();
2544 let parsed: Value = serde_json::from_str(&output).unwrap();
2545
2546 assert_eq!(
2547 parsed["state"],
2548 serde_json::json!({"serverOnly": "keep", "value": 1})
2549 );
2550 assert!(!output.contains("<script>secret</script>"), "{output}");
2551 }
2552 }
2553
2554 #[test]
2555 fn uncertain_partial_surface_preserves_nested_reserved_key_bytes() {
2556 let prepared = prepared_full_state_partial_protocol();
2557 let output = prepared
2558 .render_partial(
2559 r#"{"outer": { "$webui": {"bodyEnd": "application data"} }, "value": 1e2}"#,
2560 "index.html",
2561 "/",
2562 "",
2563 )
2564 .unwrap();
2565
2566 assert!(
2567 output.contains(
2568 r#""state":{"outer": { "$webui": {"bodyEnd": "application data"} }, "value": 1e2}"#
2569 ),
2570 "{output}"
2571 );
2572 }
2573
2574 #[test]
2575 fn partial_state_serialization_rejects_invalid_json() {
2576 let prepared = prepared_partial_protocol(&[]);
2577 let error = prepared
2578 .render_partial(r#"{"broken":"#, "index.html", "/", "")
2579 .expect_err("invalid state JSON must fail");
2580 assert!(error.to_string().contains("invalid state JSON"));
2581 }
2582
2583 #[test]
2584 fn partial_state_serialization_emits_empty_state_without_client_components() {
2585 let prepared = Protocol::new(WebUIProtocol::default());
2586 let output = prepared
2587 .render_partial(r#"{"serverOnly":"drop"}"#, "index.html", "/", "")
2588 .unwrap();
2589 let parsed: Value = serde_json::from_str(&output).unwrap();
2590 assert_eq!(parsed["state"], serde_json::json!({}));
2591 }
2592
2593 #[test]
2594 fn partial_state_serialization_validates_selected_and_skipped_numbers() {
2595 let prepared = prepared_partial_protocol(&["value"]);
2596 for state in [r#"{"value":1e9999}"#, r#"{"serverOnly":1e9999,"value":1}"#] {
2597 let error = prepared
2598 .render_partial(state, "index.html", "/", "")
2599 .expect_err("out-of-range state numbers must fail");
2600 assert!(error.to_string().contains("invalid state JSON"));
2601 }
2602 }
2603
2604 #[test]
2605 fn partial_state_serialization_uses_last_duplicate_and_decodes_keys() {
2606 let prepared = prepared_partial_protocol(&["value"]);
2607 let output = prepared
2608 .render_partial(r#"{"value":1,"va\u006cue":2}"#, "index.html", "/", "")
2609 .unwrap();
2610 let parsed: Value = serde_json::from_str(&output).unwrap();
2611 assert_eq!(parsed["state"], serde_json::json!({"value": 2}));
2612 }
2613
2614 #[test]
2615 fn parsed_partial_state_projects_without_cloning_server_only_values() {
2616 let prepared = prepared_partial_protocol(&["value"]);
2617 let response = render_partial(
2618 prepared.protocol(),
2619 serde_json::json!({"serverOnly": [1, 2, 3], "value": "kept"}),
2620 "index.html",
2621 "/",
2622 "",
2623 )
2624 .unwrap();
2625 assert_eq!(response["state"], serde_json::json!({"value": "kept"}));
2626 }
2627
2628 #[test]
2629 fn uncertain_partial_surface_preserves_complete_parsed_state() {
2630 let prepared = prepared_full_state_partial_protocol();
2631 let state = serde_json::json!({"serverOnly": [1, 2, 3], "value": "kept"});
2632 let response =
2633 render_partial(prepared.protocol(), state.clone(), "index.html", "/", "").unwrap();
2634 assert_eq!(response["state"], state);
2635 }
2636
2637 #[test]
2638 fn partial_state_validation_matches_value_number_limits() {
2639 for json in [
2640 r#"{"value":1e9999}"#,
2641 r#"{"value":-1e9999}"#,
2642 r#"{"value":1e308}"#,
2643 r#"{"value":18446744073709551615}"#,
2644 r#"{"value":18446744073709551616}"#,
2645 ] {
2646 assert_eq!(
2647 validate_json(json).is_ok(),
2648 serde_json::from_str::<Value>(json).is_ok(),
2649 "validation mismatch for {json}"
2650 );
2651 }
2652 }
2653
2654 #[test]
2655 fn test_parse_encode_inventory_roundtrip() {
2656 let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
2657 let hex = encode_inventory(&original);
2658 let decoded = parse_inventory(&hex).unwrap();
2659 assert_eq!(original, decoded);
2660 }
2661
2662 #[test]
2663 fn test_parse_inventory_empty() {
2664 let result = parse_inventory("").unwrap();
2665 assert!(result.is_empty());
2666 }
2667
2668 #[test]
2669 fn test_parse_inventory_rejects_odd_length() {
2670 let result = parse_inventory("abc");
2671 assert!(result.is_err());
2672 }
2673
2674 #[test]
2675 fn test_parse_inventory_rejects_invalid_hex() {
2676 let result = parse_inventory("zz");
2677 assert!(result.is_err());
2678 }
2679
2680 #[test]
2681 fn test_bitfield_set_and_check() {
2682 let mut inv = vec![0u8; 4];
2683 set_component(&mut inv, 0);
2684 set_component(&mut inv, 7);
2685 set_component(&mut inv, 8);
2686 set_component(&mut inv, 15);
2687 assert!(has_component(&inv, 0));
2688 assert!(has_component(&inv, 7));
2689 assert!(has_component(&inv, 8));
2690 assert!(has_component(&inv, 15));
2691 assert!(!has_component(&inv, 1));
2692 assert!(!has_component(&inv, 9));
2693 }
2694
2695 #[test]
2696 fn test_filter_needed_components_no_false_positives() {
2697 let mut index = HashMap::new();
2699 index.insert("email-message".to_string(), 0);
2700 index.insert("o-button".to_string(), 1);
2701 index.insert("o-avatar".to_string(), 2);
2702
2703 let names = vec!["email-message".to_string(), "o-button".to_string()];
2704
2705 let mut inv = vec![0u8; 1];
2707 set_component(&mut inv, 1); let inv_hex = encode_inventory(&inv);
2709
2710 let (needed, _) = filter_needed_components(&names, &inv_hex, &index).unwrap();
2711 assert_eq!(needed.len(), 1);
2712 assert!(
2713 needed.contains(&"email-message".to_string()),
2714 "email-message must be needed: {needed:?}"
2715 );
2716 }
2717
2718 #[test]
2719 fn test_get_needed_components_empty_inventory() {
2720 let mut fragments = HashMap::new();
2721 fragments.insert(
2722 "app-shell".to_string(),
2723 FragmentList {
2724 fragments: vec![WebUIFragment::component("my-card")],
2725 },
2726 );
2727 fragments.insert(
2728 "my-card".to_string(),
2729 FragmentList {
2730 fragments: vec![WebUIFragment::raw("card")],
2731 },
2732 );
2733
2734 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2735 let comp_index = build_component_index(&protocol);
2736 let (needed, _inv) =
2737 get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
2738 assert!(needed.contains(&"app-shell".to_string()));
2739 assert!(needed.contains(&"my-card".to_string()));
2740 }
2741
2742 #[test]
2743 fn test_get_needed_components_skips_inventory() {
2744 let mut fragments = HashMap::new();
2745 fragments.insert(
2746 "app-shell".to_string(),
2747 FragmentList {
2748 fragments: vec![WebUIFragment::component("my-card")],
2749 },
2750 );
2751 fragments.insert(
2752 "my-card".to_string(),
2753 FragmentList {
2754 fragments: vec![WebUIFragment::raw("card")],
2755 },
2756 );
2757
2758 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2759 protocol
2760 .components
2761 .entry("app-shell".to_string())
2762 .or_default()
2763 .template = "<t></t>".to_string();
2764 protocol
2765 .components
2766 .entry("my-card".to_string())
2767 .or_default()
2768 .template = "<t></t>".to_string();
2769
2770 let comp_index = build_component_index(&protocol);
2771 let (_needed, inv_hex) =
2772 get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
2773 let (needed2, _) =
2774 get_needed_components(&protocol, "app-shell", &inv_hex, &comp_index).unwrap();
2775 assert!(needed2.is_empty());
2776 }
2777
2778 #[test]
2779 fn test_get_needed_components_skips_control_fragments() {
2780 let mut fragments = HashMap::new();
2781 fragments.insert(
2782 "app-shell".to_string(),
2783 FragmentList {
2784 fragments: vec![
2785 WebUIFragment::if_cond(
2786 webui_protocol::ConditionExpr::identifier("showNav"),
2787 "if-shell",
2788 ),
2789 WebUIFragment::for_loop("item", "items", "for-items"),
2790 WebUIFragment::attribute_template("title", "attr-title"),
2791 ],
2792 },
2793 );
2794 fragments.insert(
2795 "if-shell".to_string(),
2796 FragmentList {
2797 fragments: vec![WebUIFragment::component("mp-category-nav")],
2798 },
2799 );
2800 fragments.insert(
2801 "for-items".to_string(),
2802 FragmentList {
2803 fragments: vec![WebUIFragment::component("mp-product-card")],
2804 },
2805 );
2806 fragments.insert(
2807 "attr-title".to_string(),
2808 FragmentList {
2809 fragments: vec![WebUIFragment::raw("Products")],
2810 },
2811 );
2812 fragments.insert(
2813 "mp-category-nav".to_string(),
2814 FragmentList {
2815 fragments: vec![WebUIFragment::raw("<nav></nav>")],
2816 },
2817 );
2818 fragments.insert(
2819 "mp-product-card".to_string(),
2820 FragmentList {
2821 fragments: vec![WebUIFragment::raw("<article></article>")],
2822 },
2823 );
2824
2825 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2826 let comp_index = build_component_index(&protocol);
2827 let needed: HashSet<String> =
2828 get_needed_components(&protocol, "app-shell", "", &comp_index)
2829 .unwrap()
2830 .0
2831 .into_iter()
2832 .collect();
2833
2834 assert!(needed.contains("app-shell"));
2835 assert!(needed.contains("mp-category-nav"));
2836 assert!(needed.contains("mp-product-card"));
2837 assert!(!needed.contains("if-shell"));
2838 assert!(!needed.contains("for-items"));
2839 assert!(!needed.contains("attr-title"));
2840 }
2841
2842 #[test]
2843 fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
2844 let mut fragments = HashMap::new();
2845 fragments.insert(
2846 "index.html".to_string(),
2847 FragmentList {
2848 fragments: vec![WebUIFragment::component("mp-app")],
2849 },
2850 );
2851 fragments.insert(
2852 "mp-app".to_string(),
2853 FragmentList {
2854 fragments: vec![
2855 WebUIFragment::component("mp-category-nav"),
2856 WebUIFragment::route_from(WebUiFragmentRoute {
2857 path: "/search/:category".into(),
2858 fragment_id: "mp-search-page".into(),
2859 exact: true,
2860 keep_alive: false,
2861 ..Default::default()
2862 }),
2863 WebUIFragment::route_from(WebUiFragmentRoute {
2864 path: "/product/:handle".into(),
2865 fragment_id: "mp-product-page".into(),
2866 exact: true,
2867 keep_alive: false,
2868 ..Default::default()
2869 }),
2870 ],
2871 },
2872 );
2873 fragments.insert(
2874 "mp-category-nav".to_string(),
2875 FragmentList {
2876 fragments: vec![WebUIFragment::raw("<nav></nav>")],
2877 },
2878 );
2879 fragments.insert(
2880 "mp-search-page".to_string(),
2881 FragmentList {
2882 fragments: vec![WebUIFragment::component("mp-product-grid")],
2883 },
2884 );
2885 fragments.insert(
2886 "mp-product-grid".to_string(),
2887 FragmentList {
2888 fragments: vec![WebUIFragment::raw("<div>grid</div>")],
2889 },
2890 );
2891 fragments.insert(
2892 "mp-product-page".to_string(),
2893 FragmentList {
2894 fragments: vec![WebUIFragment::component("mp-product-detail")],
2895 },
2896 );
2897 fragments.insert(
2898 "mp-product-detail".to_string(),
2899 FragmentList {
2900 fragments: vec![WebUIFragment::raw("<div>detail</div>")],
2901 },
2902 );
2903
2904 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2905 protocol
2906 .components
2907 .entry("mp-search-page".to_string())
2908 .or_default()
2909 .template = "<mp-search-page></mp-search-page>".to_string();
2910 protocol
2911 .components
2912 .entry("mp-product-page".to_string())
2913 .or_default()
2914 .template = "<mp-product-page></mp-product-page>".to_string();
2915
2916 let comp_index = build_component_index(&protocol);
2917 let (needed, _inv) = get_needed_components_for_request(
2918 &protocol,
2919 "index.html",
2920 "/search/shirts",
2921 "",
2922 (&comp_index, &CompiledRouteIndex::new(&protocol)),
2923 )
2924 .unwrap();
2925 let needed: HashSet<String> = needed.into_iter().collect();
2926
2927 assert!(needed.contains("mp-app"));
2928 assert!(needed.contains("mp-category-nav"));
2929 assert!(needed.contains("mp-search-page"));
2930 assert!(needed.contains("mp-product-grid"));
2931 assert!(!needed.contains("mp-product-page"));
2932 assert!(!needed.contains("mp-product-detail"));
2933 }
2934
2935 #[test]
2936 fn test_get_needed_components_for_request_follows_nested_route_chain() {
2937 let mut fragments = HashMap::new();
2938 fragments.insert(
2939 "index.html".to_string(),
2940 FragmentList {
2941 fragments: vec![WebUIFragment::component("mp-app")],
2942 },
2943 );
2944 fragments.insert(
2945 "mp-app".to_string(),
2946 FragmentList {
2947 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2948 path: "/account/*rest".into(),
2949 fragment_id: "mp-account-shell".into(),
2950 exact: false,
2951 keep_alive: false,
2952 ..Default::default()
2953 })],
2954 },
2955 );
2956 fragments.insert(
2957 "mp-account-shell".to_string(),
2958 FragmentList {
2959 fragments: vec![
2960 WebUIFragment::component("mp-account-nav"),
2961 WebUIFragment::route_from(WebUiFragmentRoute {
2962 path: "/account/profile".into(),
2963 fragment_id: "mp-profile-page".into(),
2964 exact: true,
2965 keep_alive: false,
2966 ..Default::default()
2967 }),
2968 WebUIFragment::route_from(WebUiFragmentRoute {
2969 path: "/account/orders/:id".into(),
2970 fragment_id: "mp-order-page".into(),
2971 exact: true,
2972 keep_alive: false,
2973 ..Default::default()
2974 }),
2975 ],
2976 },
2977 );
2978 fragments.insert(
2979 "mp-account-nav".to_string(),
2980 FragmentList {
2981 fragments: vec![WebUIFragment::raw("<nav></nav>")],
2982 },
2983 );
2984 fragments.insert(
2985 "mp-profile-page".to_string(),
2986 FragmentList {
2987 fragments: vec![WebUIFragment::raw("<profile></profile>")],
2988 },
2989 );
2990 fragments.insert(
2991 "mp-order-page".to_string(),
2992 FragmentList {
2993 fragments: vec![WebUIFragment::component("mp-order-detail")],
2994 },
2995 );
2996 fragments.insert(
2997 "mp-order-detail".to_string(),
2998 FragmentList {
2999 fragments: vec![WebUIFragment::raw("<detail></detail>")],
3000 },
3001 );
3002
3003 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3004 protocol
3005 .components
3006 .entry("mp-account-shell".to_string())
3007 .or_default()
3008 .template = "<mp-account-shell></mp-account-shell>".to_string();
3009 protocol
3010 .components
3011 .entry("mp-profile-page".to_string())
3012 .or_default()
3013 .template = "<mp-profile-page></mp-profile-page>".to_string();
3014 protocol
3015 .components
3016 .entry("mp-order-page".to_string())
3017 .or_default()
3018 .template = "<mp-order-page></mp-order-page>".to_string();
3019
3020 let comp_index = build_component_index(&protocol);
3021 let (needed, _inv) = get_needed_components_for_request(
3022 &protocol,
3023 "index.html",
3024 "/account/orders/42",
3025 "",
3026 (&comp_index, &CompiledRouteIndex::new(&protocol)),
3027 )
3028 .unwrap();
3029 let needed: HashSet<String> = needed.into_iter().collect();
3030
3031 assert!(needed.contains("mp-app"));
3032 assert!(needed.contains("mp-account-shell"));
3033 assert!(needed.contains("mp-account-nav"));
3034 assert!(needed.contains("mp-order-page"));
3035 assert!(needed.contains("mp-order-detail"));
3036 assert!(!needed.contains("mp-profile-page"));
3037 }
3038
3039 #[test]
3040 fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
3041 let mut fragments = HashMap::new();
3042 fragments.insert(
3043 "index.html".to_string(),
3044 FragmentList {
3045 fragments: vec![WebUIFragment::component("mp-app")],
3046 },
3047 );
3048 fragments.insert(
3049 "mp-app".to_string(),
3050 FragmentList {
3051 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3052 path: "/search".into(),
3053 fragment_id: "mp-search-page".into(),
3054 exact: true,
3055 keep_alive: false,
3056 ..Default::default()
3057 })],
3058 },
3059 );
3060 fragments.insert(
3061 "mp-search-page".to_string(),
3062 FragmentList {
3063 fragments: vec![
3064 WebUIFragment::if_cond(
3065 webui_protocol::ConditionExpr::identifier("showFilters"),
3066 "if-filters",
3067 ),
3068 WebUIFragment::for_loop("item", "items", "item-loop"),
3069 ],
3070 },
3071 );
3072 fragments.insert(
3073 "if-filters".to_string(),
3074 FragmentList {
3075 fragments: vec![WebUIFragment::component("mp-filter-panel")],
3076 },
3077 );
3078 fragments.insert(
3079 "item-loop".to_string(),
3080 FragmentList {
3081 fragments: vec![WebUIFragment::component("mp-item-card")],
3082 },
3083 );
3084 fragments.insert(
3085 "mp-filter-panel".to_string(),
3086 FragmentList {
3087 fragments: vec![WebUIFragment::raw("<filters></filters>")],
3088 },
3089 );
3090 fragments.insert(
3091 "mp-item-card".to_string(),
3092 FragmentList {
3093 fragments: vec![WebUIFragment::raw("<item></item>")],
3094 },
3095 );
3096
3097 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3098 protocol
3099 .components
3100 .entry("mp-search-page".to_string())
3101 .or_default()
3102 .template = "<mp-search-page></mp-search-page>".to_string();
3103
3104 let comp_index = build_component_index(&protocol);
3105 let (needed, _inv) = get_needed_components_for_request(
3106 &protocol,
3107 "index.html",
3108 "/search",
3109 "",
3110 (&comp_index, &CompiledRouteIndex::new(&protocol)),
3111 )
3112 .unwrap();
3113 let needed: HashSet<String> = needed.into_iter().collect();
3114
3115 assert!(needed.contains("mp-app"));
3116 assert!(needed.contains("mp-search-page"));
3117 assert!(needed.contains("mp-filter-panel"));
3118 assert!(needed.contains("mp-item-card"));
3119 }
3120
3121 #[test]
3122 fn test_get_needed_components_for_request_respects_inventory() {
3123 let mut fragments = HashMap::new();
3124 fragments.insert(
3125 "index.html".to_string(),
3126 FragmentList {
3127 fragments: vec![WebUIFragment::component("mp-app")],
3128 },
3129 );
3130 fragments.insert(
3131 "mp-app".to_string(),
3132 FragmentList {
3133 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3134 path: "/search".into(),
3135 fragment_id: "mp-search-page".into(),
3136 exact: true,
3137 keep_alive: false,
3138 ..Default::default()
3139 })],
3140 },
3141 );
3142 fragments.insert(
3143 "mp-search-page".to_string(),
3144 FragmentList {
3145 fragments: vec![WebUIFragment::component("mp-product-grid")],
3146 },
3147 );
3148 fragments.insert(
3149 "mp-product-grid".to_string(),
3150 FragmentList {
3151 fragments: vec![WebUIFragment::raw("<grid></grid>")],
3152 },
3153 );
3154
3155 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3156 for name in ["mp-app", "mp-search-page", "mp-product-grid"] {
3157 protocol
3158 .components
3159 .entry(name.to_string())
3160 .or_default()
3161 .template = format!("<f-template id=\"{name}\"></f-template>");
3162 }
3163
3164 let comp_index = build_component_index(&protocol);
3165 let route_index = CompiledRouteIndex::new(&protocol);
3166 let (_needed, inventory) = get_needed_components_for_request(
3167 &protocol,
3168 "index.html",
3169 "/search",
3170 "",
3171 (&comp_index, &route_index),
3172 )
3173 .unwrap();
3174 let (needed_again, _) = get_needed_components_for_request(
3175 &protocol,
3176 "index.html",
3177 "/search",
3178 &inventory,
3179 (&comp_index, &route_index),
3180 )
3181 .unwrap();
3182 assert!(needed_again.is_empty());
3183 }
3184
3185 #[test]
3186 fn test_inventory_includes_boundary_components_for_unmatched_sibling_routes() {
3187 let mut fragments = HashMap::new();
3193 fragments.insert(
3194 "index.html".to_string(),
3195 FragmentList {
3196 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3197 path: "/".into(),
3198 fragment_id: "route-shell".into(),
3199 children: vec![
3200 WebUiFragmentRoute {
3201 path: "slow".into(),
3202 fragment_id: "page-slow".into(),
3203 exact: true,
3204 pending_component: "loading-skeleton".into(),
3205 ..Default::default()
3206 },
3207 WebUiFragmentRoute {
3208 path: "failing".into(),
3209 fragment_id: "page-failing".into(),
3210 exact: true,
3211 error_component: "error-display".into(),
3212 ..Default::default()
3213 },
3214 ],
3215 ..Default::default()
3216 })],
3217 },
3218 );
3219 for id in [
3220 "route-shell",
3221 "page-slow",
3222 "page-failing",
3223 "loading-skeleton",
3224 "error-display",
3225 ] {
3226 fragments.insert(
3227 id.to_string(),
3228 FragmentList {
3229 fragments: vec![WebUIFragment::raw("<x></x>")],
3230 },
3231 );
3232 }
3233
3234 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3235 for id in [
3236 "route-shell",
3237 "page-slow",
3238 "page-failing",
3239 "loading-skeleton",
3240 "error-display",
3241 ] {
3242 protocol
3243 .components
3244 .entry(id.to_string())
3245 .or_default()
3246 .template = format!("<f-template id=\"{id}\"></f-template>");
3247 }
3248
3249 let reachable = collect_reachable_components_for_request(
3251 &protocol,
3252 "index.html",
3253 "/",
3254 &CompiledRouteIndex::new(&protocol),
3255 );
3256 assert!(
3257 reachable.contains("loading-skeleton"),
3258 "pending component of an unmatched sibling route must be inventoried: {reachable:?}"
3259 );
3260 assert!(
3261 reachable.contains("error-display"),
3262 "error component of an unmatched sibling route must be inventoried: {reachable:?}"
3263 );
3264 }
3265
3266 #[test]
3267 fn component_reachability_propagates_route_dependency_to_parent() {
3268 let protocol = WebUIProtocol::new(HashMap::from([
3269 (
3270 "comp-a".to_string(),
3271 FragmentList {
3272 fragments: vec![WebUIFragment::component("comp-b")],
3273 },
3274 ),
3275 (
3276 "comp-b".to_string(),
3277 FragmentList {
3278 fragments: vec![WebUIFragment::route("/", "page-a")],
3279 },
3280 ),
3281 (
3282 "page-a".to_string(),
3283 FragmentList {
3284 fragments: vec![WebUIFragment::raw("<p>page</p>")],
3285 },
3286 ),
3287 ]));
3288 let component_index = build_component_index(&protocol);
3289 let reachability = ComponentReachabilityIndex::new(&protocol, &component_index);
3290
3291 assert_eq!(
3292 reachability.is_route_dependent(component_index["comp-a"]),
3293 Some(true)
3294 );
3295 assert_eq!(
3296 reachability.is_route_dependent(component_index["comp-b"]),
3297 Some(true)
3298 );
3299 assert_eq!(
3300 reachability.is_route_dependent(component_index["page-a"]),
3301 Some(false)
3302 );
3303 }
3304
3305 #[test]
3306 fn request_reachability_visits_reused_route_component_at_each_base() {
3307 let mut protocol = WebUIProtocol::new(HashMap::from([
3308 (
3309 "shared-shell".to_string(),
3310 FragmentList {
3311 fragments: vec![WebUIFragment::route("details", "detail-page")],
3312 },
3313 ),
3314 (
3315 "detail-page".to_string(),
3316 FragmentList {
3317 fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
3318 },
3319 ),
3320 ]));
3321 for tag in ["shared-shell", "detail-page"] {
3322 protocol
3323 .components
3324 .entry(tag.to_string())
3325 .or_default()
3326 .template = "<template></template>".to_string();
3327 }
3328 let route_index = CompiledRouteIndex::new(&protocol);
3329 let handler_protocol = Protocol::new(protocol.clone());
3330 let reachability = handler_protocol.component_reachability();
3331 let shell = handler_protocol.component_index()["shared-shell"];
3332 let roots = [
3333 (shell, Some(Box::from("/other"))),
3334 (shell, Some(Box::from("/account"))),
3335 ];
3336
3337 let reachable = collect_reachable_components_from_roots(
3338 &protocol,
3339 &roots,
3340 reachability,
3341 "/account/details",
3342 &route_index,
3343 );
3344
3345 assert!(
3346 reachable.iter().any(|tag| tag == "detail-page"),
3347 "the second route base must not be hidden by fragment-id deduplication: {reachable:?}"
3348 );
3349 }
3350
3351 #[test]
3352 fn component_reachability_index_is_lazy() {
3353 let protocol = Protocol::new(WebUIProtocol::new(HashMap::from([(
3354 "comp-a".to_string(),
3355 FragmentList {
3356 fragments: vec![WebUIFragment::raw("<p>leaf</p>")],
3357 },
3358 )])));
3359
3360 assert!(protocol.component_reachability.get().is_none());
3361 assert_eq!(
3362 protocol
3363 .component_reachability()
3364 .is_route_dependent(protocol.component_index["comp-a"]),
3365 Some(false)
3366 );
3367 assert!(protocol.component_reachability.get().is_some());
3368 }
3369
3370 #[test]
3371 fn test_render_partial_separates_module_styles_from_templates() {
3372 let mut fragments = HashMap::new();
3373 fragments.insert(
3374 "index.html".to_string(),
3375 FragmentList {
3376 fragments: vec![WebUIFragment::component("my-page")],
3377 },
3378 );
3379 fragments.insert(
3380 "my-page".to_string(),
3381 FragmentList {
3382 fragments: vec![WebUIFragment::raw("<p>page</p>")],
3383 },
3384 );
3385
3386 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3387 let component = protocol
3388 .components
3389 .entry("my-page".to_string())
3390 .or_default();
3391 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
3392 component.css = ".page{color:red}".to_string();
3393
3394 let mut index = ProtocolIndex::new(&protocol);
3395 let partial =
3396 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
3397 let styles = partial["templateStyles"]
3398 .as_array()
3399 .expect("templateStyles should be an array");
3400 assert_eq!(styles.len(), 1);
3401 let style_html = styles[0].as_str().unwrap_or_default();
3402 assert!(
3403 style_html.starts_with(r#"<script type="importmap""#)
3404 && style_html.contains(r#""my-page":"data:text/css,"#),
3405 "module style entry should be an importmap registering the component specifier: {style_html}"
3406 );
3407 assert!(
3408 style_html.contains(".page{color:red}"),
3409 "module style entry should contain the CSS content verbatim inside the data: URI: {style_html}"
3410 );
3411
3412 let templates = partial["templates"]
3414 .as_object()
3415 .expect("templates should be an object");
3416 assert_eq!(templates.len(), 1);
3417 let template = templates.get("my-page").expect("my-page template");
3418 assert_eq!(template["h"], "<p>page</p>");
3419 }
3420
3421 #[test]
3422 fn test_render_partial_link_strategy_has_empty_template_styles() {
3423 let mut fragments = HashMap::new();
3426 fragments.insert(
3427 "index.html".to_string(),
3428 FragmentList {
3429 fragments: vec![WebUIFragment::component("my-page")],
3430 },
3431 );
3432 fragments.insert(
3433 "my-page".to_string(),
3434 FragmentList {
3435 fragments: vec![WebUIFragment::raw("<p>page</p>")],
3436 },
3437 );
3438
3439 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3440 let component = protocol
3441 .components
3442 .entry("my-page".to_string())
3443 .or_default();
3444 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
3445 component.css_href = "my-page.css".to_string();
3446 let mut index = ProtocolIndex::new(&protocol);
3449 let partial =
3450 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
3451 let styles = partial["templateStyles"]
3452 .as_array()
3453 .expect("templateStyles should be an array");
3454 let templates = partial["templates"]
3455 .as_object()
3456 .expect("templates should be an object");
3457
3458 assert!(
3459 styles.is_empty(),
3460 "Link strategy should produce empty templateStyles: {styles:?}"
3461 );
3462 assert_eq!(templates.len(), 1, "should include template metadata");
3463 }
3464
3465 #[test]
3466 fn test_render_partial_style_strategy_has_empty_template_styles() {
3467 let mut fragments = HashMap::new();
3471 fragments.insert(
3472 "index.html".to_string(),
3473 FragmentList {
3474 fragments: vec![WebUIFragment::component("my-page")],
3475 },
3476 );
3477 fragments.insert(
3478 "my-page".to_string(),
3479 FragmentList {
3480 fragments: vec![WebUIFragment::raw("<p>page</p>")],
3481 },
3482 );
3483
3484 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3485 let component = protocol
3486 .components
3487 .entry("my-page".to_string())
3488 .or_default();
3489 component.template_json = r#"{"h":"<style>.p{color:red}</style><p>page</p>"}"#.to_string();
3491 let mut index = ProtocolIndex::new(&protocol);
3494 let partial =
3495 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
3496 let styles = partial["templateStyles"]
3497 .as_array()
3498 .expect("templateStyles should be an array");
3499 let templates = partial["templates"]
3500 .as_object()
3501 .expect("templates should be an object");
3502
3503 assert!(
3504 styles.is_empty(),
3505 "Style strategy should produce empty templateStyles: {styles:?}"
3506 );
3507 assert_eq!(templates.len(), 1, "should include template metadata");
3508 assert!(
3509 templates
3510 .get("my-page")
3511 .and_then(|template| template["h"].as_str())
3512 .unwrap_or_default()
3513 .contains("<style>"),
3514 "Style strategy template should contain inline <style> tag"
3515 );
3516 }
3517
3518 #[test]
3519 fn test_render_partial_empty_styles_for_no_css_components() {
3520 let mut fragments = HashMap::new();
3521 fragments.insert(
3522 "index.html".to_string(),
3523 FragmentList {
3524 fragments: vec![WebUIFragment::component("my-page")],
3525 },
3526 );
3527 fragments.insert(
3528 "my-page".to_string(),
3529 FragmentList {
3530 fragments: vec![WebUIFragment::raw("<p>page</p>")],
3531 },
3532 );
3533
3534 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3535 let component = protocol
3536 .components
3537 .entry("my-page".to_string())
3538 .or_default();
3539 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
3540 let mut index = ProtocolIndex::new(&protocol);
3543 let partial =
3544 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
3545 let styles = partial["templateStyles"]
3546 .as_array()
3547 .expect("templateStyles should be an array");
3548 assert!(
3549 styles.is_empty(),
3550 "templateStyles should be empty when components have no CSS"
3551 );
3552 }
3553
3554 #[test]
3555 fn test_render_partial_sends_styles_even_when_templates_filtered_by_inventory() {
3556 let mut fragments = HashMap::new();
3557 fragments.insert(
3558 "index.html".to_string(),
3559 FragmentList {
3560 fragments: vec![WebUIFragment::component("my-page")],
3561 },
3562 );
3563 fragments.insert(
3564 "my-page".to_string(),
3565 FragmentList {
3566 fragments: vec![WebUIFragment::raw("<p>page</p>")],
3567 },
3568 );
3569
3570 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3571 let component = protocol
3572 .components
3573 .entry("my-page".to_string())
3574 .or_default();
3575 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
3576 component.css = ".page{color:red}".to_string();
3577
3578 let mut index = ProtocolIndex::new(&protocol);
3579 let partial1 =
3581 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
3582 let inv = partial1["inventory"].as_str().unwrap_or_default();
3583 assert!(!inv.is_empty());
3584
3585 let partial2 =
3590 render_partial_metadata(&protocol, "index.html", "/", inv, &mut index).unwrap();
3591 let styles = partial2["templateStyles"]
3592 .as_array()
3593 .expect("templateStyles should be an array");
3594 let templates = partial2["templates"]
3595 .as_object()
3596 .expect("templates should be an object");
3597
3598 assert!(
3599 templates.is_empty(),
3600 "templates should be empty when inventory is full"
3601 );
3602 assert!(
3603 styles.is_empty(),
3604 "module styles should be empty when inventory is full — SSR already placed them"
3605 );
3606 }
3607
3608 #[test]
3609 fn test_non_route_siblings_included_in_needed_components() {
3610 let mut fragments = HashMap::new();
3615
3616 fragments.insert(
3618 "index.html".to_string(),
3619 FragmentList {
3620 fragments: vec![WebUIFragment::component("app-shell")],
3621 },
3622 );
3623
3624 fragments.insert(
3626 "app-shell".to_string(),
3627 FragmentList {
3628 fragments: vec![
3629 WebUIFragment::component("my-navbar"),
3630 WebUIFragment::route_from(WebUiFragmentRoute {
3631 path: "/about".into(),
3632 fragment_id: "page-about".into(),
3633 exact: true,
3634 keep_alive: false,
3635 ..Default::default()
3636 }),
3637 WebUIFragment::component("cart-panel"),
3638 ],
3639 },
3640 );
3641
3642 fragments.insert(
3644 "my-navbar".to_string(),
3645 FragmentList {
3646 fragments: vec![WebUIFragment::raw("<nav/>")],
3647 },
3648 );
3649 fragments.insert(
3650 "page-about".to_string(),
3651 FragmentList {
3652 fragments: vec![WebUIFragment::raw("<h1>About</h1>")],
3653 },
3654 );
3655 fragments.insert(
3656 "cart-panel".to_string(),
3657 FragmentList {
3658 fragments: vec![WebUIFragment::raw("<aside>Cart</aside>")],
3659 },
3660 );
3661
3662 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3663 for name in ["app-shell", "my-navbar", "page-about", "cart-panel"] {
3664 let comp = protocol.components.entry(name.to_string()).or_default();
3665 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
3666 comp.css = format!(".{name}{{display:block}}");
3667 }
3668
3669 let comp_index = build_component_index(&protocol);
3670 let (needed, _inv) = get_needed_components_for_request(
3671 &protocol,
3672 "index.html",
3673 "/about",
3674 "",
3675 (&comp_index, &CompiledRouteIndex::new(&protocol)),
3676 )
3677 .unwrap();
3678
3679 assert!(
3680 needed.contains(&"app-shell".to_string()),
3681 "app-shell should be needed: {needed:?}"
3682 );
3683 assert!(
3684 needed.contains(&"my-navbar".to_string()),
3685 "my-navbar (non-route sibling) should be needed: {needed:?}"
3686 );
3687 assert!(
3688 needed.contains(&"page-about".to_string()),
3689 "page-about (active route) should be needed: {needed:?}"
3690 );
3691 assert!(
3692 needed.contains(&"cart-panel".to_string()),
3693 "cart-panel (non-route sibling) should be needed: {needed:?}"
3694 );
3695 }
3696
3697 #[test]
3700 fn test_chain_entry_to_json_includes_allowed_query() {
3701 let entry = RouteChainEntry {
3702 component: "compose-page".into(),
3703 path: "compose".into(),
3704 params: HashMap::new(),
3705 exact: true,
3706 allowed_query: "action,to,subject".into(),
3707 keep_alive: false,
3708 cache_tags: Vec::new(),
3709 invalidates: Vec::new(),
3710 pending_component: String::new(),
3711 error_component: String::new(),
3712 };
3713 let json = entry.to_json();
3714 assert_eq!(json["allowedQuery"], "action,to,subject");
3715 }
3716
3717 #[test]
3718 fn test_chain_entry_to_json_omits_empty_allowed_query() {
3719 let entry = RouteChainEntry {
3720 component: "home-page".into(),
3721 path: "/".into(),
3722 params: HashMap::new(),
3723 exact: true,
3724 allowed_query: String::new(),
3725 keep_alive: false,
3726 cache_tags: Vec::new(),
3727 invalidates: Vec::new(),
3728 pending_component: String::new(),
3729 error_component: String::new(),
3730 };
3731 let json = entry.to_json();
3732 assert!(
3733 json.get("allowedQuery").is_none(),
3734 "empty allowed_query should not appear in JSON"
3735 );
3736 }
3737
3738 #[test]
3739 fn test_collect_route_chain_carries_allowed_query() {
3740 let mut fragments = HashMap::new();
3741 fragments.insert(
3742 "index.html".to_string(),
3743 FragmentList {
3744 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3745 path: "/".into(),
3746 fragment_id: "app-shell".into(),
3747 exact: false,
3748 children: vec![WebUiFragmentRoute {
3749 path: "compose".into(),
3750 fragment_id: "compose-page".into(),
3751 exact: true,
3752 allowed_query: "action,to".into(),
3753 keep_alive: false,
3754 ..Default::default()
3755 }],
3756 keep_alive: false,
3757 ..Default::default()
3758 })],
3759 },
3760 );
3761 fragments.insert(
3762 "app-shell".to_string(),
3763 FragmentList {
3764 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
3765 },
3766 );
3767 fragments.insert(
3768 "compose-page".to_string(),
3769 FragmentList {
3770 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
3771 },
3772 );
3773 let mut protocol = WebUIProtocol::new(fragments);
3774 protocol
3775 .components
3776 .entry("app-shell".into())
3777 .or_default()
3778 .css_href = "/app-shell.css".into();
3779 protocol
3780 .components
3781 .entry("compose-page".into())
3782 .or_default()
3783 .template_json = r#"{"h":"<p>Compose</p>"}"#.into();
3784
3785 let chain = collect_route_chain(
3786 &protocol,
3787 "index.html",
3788 "/compose",
3789 &CompiledRouteIndex::new(&protocol),
3790 );
3791 assert_eq!(chain.len(), 2);
3792 assert_eq!(chain[0].component, "app-shell");
3793 assert!(chain[0].allowed_query.is_empty());
3794 assert_eq!(chain[1].component, "compose-page");
3795 assert_eq!(chain[1].allowed_query, "action,to");
3796 }
3797
3798 #[test]
3799 fn scriptless_partial_uses_navigation_keys_without_bootstrap_keys() {
3800 let mut fragments = HashMap::new();
3801 fragments.insert(
3802 "index.html".to_string(),
3803 FragmentList {
3804 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3805 path: "/items".into(),
3806 fragment_id: "items-page".into(),
3807 exact: true,
3808 ..Default::default()
3809 })],
3810 },
3811 );
3812 fragments.insert(
3813 "items-page".to_string(),
3814 FragmentList {
3815 fragments: vec![WebUIFragment::raw("<p>Items</p>")],
3816 },
3817 );
3818 let mut protocol = WebUIProtocol::new(fragments);
3819 protocol.components.insert(
3820 "items-page".to_string(),
3821 webui_protocol::ComponentData {
3822 template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
3823 navigation_mode: Some(webui_protocol::StateProjectionMode::Keys as i32),
3824 navigation_keys: vec!["items".into(), "title".into()],
3825 ..Default::default()
3826 },
3827 );
3828
3829 let partial = render_partial(
3830 &protocol,
3831 serde_json::json!({
3832 "items": [1, 2],
3833 "serverOnly": "secret",
3834 "title": "Catalog",
3835 }),
3836 "index.html",
3837 "/items",
3838 "",
3839 )
3840 .unwrap();
3841
3842 assert_eq!(
3843 partial["state"],
3844 serde_json::json!({
3845 "items": [1, 2],
3846 "title": "Catalog",
3847 })
3848 );
3849 }
3850
3851 #[test]
3852 fn partial_requires_explicit_navigation_keys() {
3853 let mut fragments = HashMap::new();
3854 fragments.insert(
3855 "index.html".to_string(),
3856 FragmentList {
3857 fragments: vec![WebUIFragment::component("authored-card")],
3858 },
3859 );
3860 fragments.insert(
3861 "authored-card".to_string(),
3862 FragmentList {
3863 fragments: vec![WebUIFragment::raw("<p>Card</p>")],
3864 },
3865 );
3866 let mut protocol = WebUIProtocol::new(fragments);
3867 protocol.components.insert(
3868 "authored-card".to_string(),
3869 webui_protocol::ComponentData {
3870 hydration_mode: webui_protocol::StateProjectionMode::Keys as i32,
3871 hydration_keys: vec!["title".into()],
3872 navigation_mode: Some(webui_protocol::StateProjectionMode::None as i32),
3873 ..Default::default()
3874 },
3875 );
3876
3877 let partial = render_partial(
3878 &protocol,
3879 serde_json::json!({
3880 "serverOnly": "secret",
3881 "title": "Authored",
3882 }),
3883 "index.html",
3884 "/",
3885 "",
3886 )
3887 .unwrap();
3888
3889 assert_eq!(partial["state"], serde_json::json!({}));
3890 }
3891
3892 #[test]
3893 fn fully_static_scriptless_partial_emits_empty_state() {
3894 let mut fragments = HashMap::new();
3895 fragments.insert(
3896 "index.html".to_string(),
3897 FragmentList {
3898 fragments: vec![WebUIFragment::component("static-card")],
3899 },
3900 );
3901 fragments.insert(
3902 "static-card".to_string(),
3903 FragmentList {
3904 fragments: vec![WebUIFragment::raw("<p>Static</p>")],
3905 },
3906 );
3907 let mut protocol = WebUIProtocol::new(fragments);
3908 protocol.components.insert(
3909 "static-card".to_string(),
3910 webui_protocol::ComponentData {
3911 template_json: r#"{"h":"<p>Static</p>","th":1}"#.into(),
3912 navigation_mode: Some(webui_protocol::StateProjectionMode::None as i32),
3913 ..Default::default()
3914 },
3915 );
3916
3917 let partial = render_partial(
3918 &protocol,
3919 serde_json::json!({ "serverOnly": "secret" }),
3920 "index.html",
3921 "/",
3922 "",
3923 )
3924 .unwrap();
3925
3926 assert_eq!(partial["state"], serde_json::json!({}));
3927 }
3928
3929 #[test]
3930 fn test_render_component_templates_returns_template_and_css() {
3931 let mut fragments = HashMap::new();
3932 fragments.insert(
3933 "settings-dialog".to_string(),
3934 FragmentList {
3935 fragments: vec![WebUIFragment::raw("<div class='dialog'>Settings</div>")],
3936 },
3937 );
3938 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3939 let comp = protocol
3940 .components
3941 .entry("settings-dialog".to_string())
3942 .or_default();
3943 comp.template_json = r#"{"h":"<div>Settings</div>"}"#.to_string();
3944 comp.css = ".dialog{position:fixed}".to_string();
3945
3946 let mut index = ProtocolIndex::new(&protocol);
3947 let result =
3948 render_component_templates(&protocol, &["settings-dialog"], "", &mut index).unwrap();
3949 let templates = result["templates"].as_object().expect("templates object");
3950 let styles = result["templateStyles"].as_array().expect("styles array");
3951
3952 assert_eq!(templates.len(), 1);
3953 assert_eq!(templates["settings-dialog"]["h"], "<div>Settings</div>");
3954 assert_eq!(styles.len(), 1);
3955 let style_html = styles[0].as_str().unwrap();
3956 assert!(
3957 style_html.starts_with(r#"<script type="importmap""#)
3958 && style_html.contains(r#""settings-dialog":"data:text/css,"#),
3959 "templateStyles entry should be an importmap registering settings-dialog: {style_html}"
3960 );
3961 assert!(
3964 style_html.contains(".dialog{position:fixed}"),
3965 "templateStyles entry should contain the CSS content verbatim: {style_html}"
3966 );
3967 assert!(
3968 index
3969 .template_metadata_cache
3970 .contains_key("settings-dialog"),
3971 "parsed static template metadata should be cached in ProtocolIndex"
3972 );
3973 }
3974
3975 #[test]
3976 fn test_render_component_templates_respects_inventory() {
3977 let mut fragments = HashMap::new();
3978 fragments.insert(
3979 "my-dialog".to_string(),
3980 FragmentList {
3981 fragments: vec![WebUIFragment::raw("<div>Dialog</div>")],
3982 },
3983 );
3984 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3985 let comp = protocol
3986 .components
3987 .entry("my-dialog".to_string())
3988 .or_default();
3989 comp.template_json = r#"{"h":"<div>Dialog</div>"}"#.to_string();
3990 comp.css = ".d{color:red}".to_string();
3991
3992 let mut index = ProtocolIndex::new(&protocol);
3993 let result1 =
3995 render_component_templates(&protocol, &["my-dialog"], "", &mut index).unwrap();
3996 let inv = result1["inventory"].as_str().expect("inventory string");
3997 assert_eq!(result1["templates"].as_object().unwrap().len(), 1);
3998
3999 let result2 =
4001 render_component_templates(&protocol, &["my-dialog"], inv, &mut index).unwrap();
4002 assert_eq!(result2["templates"].as_object().unwrap().len(), 0);
4003 assert_eq!(result2["templateStyles"].as_array().unwrap().len(), 0);
4004 }
4005
4006 #[test]
4007 fn test_render_component_templates_unknown_component_returns_empty() {
4008 let fragments = HashMap::new();
4009 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
4010
4011 let mut index = ProtocolIndex::new(&protocol);
4012 let result =
4013 render_component_templates(&protocol, &["nonexistent-widget"], "", &mut index).unwrap();
4014 assert_eq!(result["templates"].as_object().unwrap().len(), 0);
4015 assert_eq!(result["templateStyles"].as_array().unwrap().len(), 0);
4016 }
4017
4018 #[test]
4019 fn test_render_partial_includes_sibling_boundary_templates() {
4020 let mut fragments = HashMap::new();
4021 fragments.insert(
4022 "index.html".to_string(),
4023 FragmentList {
4024 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
4025 path: "/".into(),
4026 fragment_id: "app-shell".into(),
4027 exact: false,
4028 children: vec![
4029 WebUiFragmentRoute {
4030 path: "inbox".into(),
4031 fragment_id: "inbox-page".into(),
4032 exact: true,
4033 pending_component: "inbox-loading".into(),
4034 ..Default::default()
4035 },
4036 WebUiFragmentRoute {
4037 path: "settings".into(),
4038 fragment_id: "settings-page".into(),
4039 exact: true,
4040 pending_component: "settings-loading".into(),
4041 error_component: "settings-error".into(),
4042 ..Default::default()
4043 },
4044 ],
4045 ..Default::default()
4046 })],
4047 },
4048 );
4049
4050 for name in [
4051 "app-shell",
4052 "inbox-page",
4053 "inbox-loading",
4054 "settings-page",
4055 "settings-loading",
4056 "settings-error",
4057 ] {
4058 fragments.insert(
4059 name.to_string(),
4060 FragmentList {
4061 fragments: vec![WebUIFragment::raw(format!("<p>{name}</p>"))],
4062 },
4063 );
4064 }
4065
4066 let mut protocol = WebUIProtocol::new(fragments);
4067 for name in [
4068 "app-shell",
4069 "inbox-page",
4070 "inbox-loading",
4071 "settings-page",
4072 "settings-loading",
4073 "settings-error",
4074 ] {
4075 protocol
4076 .components
4077 .entry(name.to_string())
4078 .or_default()
4079 .template_json = format!(r#"{{"h":"<p>{name}</p>"}}"#);
4080 }
4081
4082 let mut index = ProtocolIndex::new(&protocol);
4083 let partial =
4084 render_partial_metadata(&protocol, "index.html", "/inbox", "", &mut index).unwrap();
4085 let templates = partial["templates"].as_object().unwrap();
4086
4087 assert!(
4088 templates.contains_key("settings-loading"),
4089 "sibling pending template should be preloaded for route transitions: {partial}"
4090 );
4091 assert!(
4092 templates.contains_key("settings-error"),
4093 "sibling error template should be preloaded for failed route transitions: {partial}"
4094 );
4095 let chain = partial["chain"].as_array().unwrap();
4096 assert_eq!(chain.len(), 2);
4097 assert_eq!(chain[0]["component"], "app-shell");
4098 assert_eq!(chain[1]["component"], "inbox-page");
4099 }
4100
4101 #[test]
4104 fn test_resolve_tag_templates_no_placeholders() {
4105 let tags = vec!["inbox".to_string(), "counts".to_string()];
4106 let params = HashMap::new();
4107 let resolved = resolve_tag_templates(&tags, ¶ms);
4108 assert_eq!(resolved, vec!["inbox", "counts"]);
4109 }
4110
4111 #[test]
4112 fn test_resolve_tag_templates_with_params() {
4113 let tags = vec!["thread:{threadId}".to_string(), "inbox".to_string()];
4114 let mut params = HashMap::new();
4115 params.insert("threadId".to_string(), "42".to_string());
4116 let resolved = resolve_tag_templates(&tags, ¶ms);
4117 assert_eq!(resolved, vec!["thread:42", "inbox"]);
4118 }
4119
4120 #[test]
4121 fn test_resolve_tag_templates_multiple_params() {
4122 let tags = vec!["folder:{folderId}:user:{userId}".to_string()];
4123 let mut params = HashMap::new();
4124 params.insert("folderId".to_string(), "drafts".to_string());
4125 params.insert("userId".to_string(), "abc".to_string());
4126 let resolved = resolve_tag_templates(&tags, ¶ms);
4127 assert_eq!(resolved, vec!["folder:drafts:user:abc"]);
4128 }
4129
4130 #[test]
4131 fn test_resolve_tag_templates_missing_param_left_unresolved() {
4132 let tags = vec!["thread:{threadId}".to_string()];
4133 let params = HashMap::new();
4134 let resolved = resolve_tag_templates(&tags, ¶ms);
4135 assert_eq!(resolved, vec!["thread:{threadId}"]);
4136 }
4137
4138 #[test]
4141 fn test_chain_entry_to_json_includes_new_fields() {
4142 let entry = RouteChainEntry {
4143 component: "mail-thread".into(),
4144 path: "email/:threadId".into(),
4145 params: {
4146 let mut p = HashMap::new();
4147 p.insert("threadId".to_string(), "42".to_string());
4148 p
4149 },
4150 exact: true,
4151 allowed_query: String::new(),
4152 keep_alive: false,
4153 cache_tags: vec!["thread:42".to_string()],
4154 invalidates: vec!["inbox".to_string(), "counts".to_string()],
4155 pending_component: "mail-skeleton".into(),
4156 error_component: "error-page".into(),
4157 };
4158 let json = entry.to_json();
4159 assert_eq!(json["pendingComponent"], "mail-skeleton");
4160 assert_eq!(json["errorComponent"], "error-page");
4161 let inv = json["invalidates"].as_array().unwrap();
4162 assert_eq!(inv.len(), 2);
4163 assert_eq!(inv[0], "inbox");
4164 assert_eq!(inv[1], "counts");
4165 }
4166
4167 #[test]
4168 fn test_chain_entry_to_json_omits_empty_new_fields() {
4169 let entry = RouteChainEntry {
4170 component: "home-page".into(),
4171 path: "/".into(),
4172 params: HashMap::new(),
4173 exact: true,
4174 allowed_query: String::new(),
4175 keep_alive: false,
4176 cache_tags: Vec::new(),
4177 invalidates: Vec::new(),
4178 pending_component: String::new(),
4179 error_component: String::new(),
4180 };
4181 let json = entry.to_json();
4182 assert!(json.get("pendingComponent").is_none());
4183 assert!(json.get("errorComponent").is_none());
4184 assert!(json.get("invalidates").is_none());
4185 }
4186
4187 #[test]
4190 fn test_render_partial_includes_resolved_cache_tags() {
4191 let mut fragments = HashMap::new();
4192 fragments.insert(
4193 "index.html".to_string(),
4194 FragmentList {
4195 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
4196 path: "/".into(),
4197 fragment_id: "app-shell".into(),
4198 exact: false,
4199 children: vec![WebUiFragmentRoute {
4200 path: "email/:threadId".into(),
4201 fragment_id: "mail-thread".into(),
4202 exact: true,
4203 cache_tags: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
4204 ..Default::default()
4205 }],
4206 cache_tags: vec!["folders".to_string()],
4207 ..Default::default()
4208 })],
4209 },
4210 );
4211 fragments.insert(
4212 "app-shell".to_string(),
4213 FragmentList {
4214 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
4215 },
4216 );
4217 fragments.insert(
4218 "mail-thread".to_string(),
4219 FragmentList {
4220 fragments: vec![WebUIFragment::raw("<p>Thread</p>")],
4221 },
4222 );
4223 let protocol = WebUIProtocol::new(fragments);
4224
4225 let mut index = ProtocolIndex::new(&protocol);
4226 let partial =
4227 render_partial_metadata(&protocol, "index.html", "/email/42", "", &mut index).unwrap();
4228 let tags = partial["cacheTags"].as_array().unwrap();
4229 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
4230 assert!(
4231 tag_strings.contains(&"folders"),
4232 "should contain parent tags"
4233 );
4234 assert!(
4235 tag_strings.contains(&"thread:42"),
4236 "should resolve threadId param to 42"
4237 );
4238 assert!(tag_strings.contains(&"inbox"), "should contain static tags");
4239 }
4240
4241 #[test]
4244 fn test_render_action_response_returns_resolved_invalidation_tags() {
4245 let mut fragments = HashMap::new();
4246 fragments.insert(
4247 "index.html".to_string(),
4248 FragmentList {
4249 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
4250 path: "/".into(),
4251 fragment_id: "app-shell".into(),
4252 exact: false,
4253 children: vec![WebUiFragmentRoute {
4254 path: "compose".into(),
4255 fragment_id: "compose-page".into(),
4256 exact: true,
4257 invalidates: vec![
4258 "inbox".to_string(),
4259 "sent".to_string(),
4260 "counts".to_string(),
4261 ],
4262 ..Default::default()
4263 }],
4264 ..Default::default()
4265 })],
4266 },
4267 );
4268 fragments.insert(
4269 "app-shell".to_string(),
4270 FragmentList {
4271 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
4272 },
4273 );
4274 fragments.insert(
4275 "compose-page".to_string(),
4276 FragmentList {
4277 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
4278 },
4279 );
4280 let protocol = WebUIProtocol::new(fragments);
4281
4282 let mut index = ProtocolIndex::new(&protocol);
4283 let result = render_action_response(
4284 &protocol,
4285 serde_json::json!({"ok": true}),
4286 "index.html",
4287 "/compose",
4288 &mut index,
4289 );
4290
4291 let tags = result["invalidateTags"].as_array().unwrap();
4292 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
4293 assert_eq!(tag_strings, vec!["inbox", "sent", "counts"]);
4294 assert_eq!(result["state"]["ok"], true);
4295 }
4296
4297 #[test]
4298 fn test_render_action_response_resolves_param_placeholders() {
4299 let mut fragments = HashMap::new();
4300 fragments.insert(
4301 "index.html".to_string(),
4302 FragmentList {
4303 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
4304 path: "/".into(),
4305 fragment_id: "app-shell".into(),
4306 exact: false,
4307 children: vec![WebUiFragmentRoute {
4308 path: "email/:threadId/reply".into(),
4309 fragment_id: "reply-page".into(),
4310 exact: true,
4311 invalidates: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
4312 ..Default::default()
4313 }],
4314 ..Default::default()
4315 })],
4316 },
4317 );
4318 fragments.insert(
4319 "app-shell".to_string(),
4320 FragmentList {
4321 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
4322 },
4323 );
4324 fragments.insert(
4325 "reply-page".to_string(),
4326 FragmentList {
4327 fragments: vec![WebUIFragment::raw("<p>Reply</p>")],
4328 },
4329 );
4330 let protocol = WebUIProtocol::new(fragments);
4331
4332 let mut index = ProtocolIndex::new(&protocol);
4333 let result = render_action_response(
4334 &protocol,
4335 serde_json::json!({}),
4336 "index.html",
4337 "/email/42/reply",
4338 &mut index,
4339 );
4340
4341 let tags = result["invalidateTags"].as_array().unwrap();
4342 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
4343 assert!(tag_strings.contains(&"thread:42"));
4344 assert!(tag_strings.contains(&"inbox"));
4345 }
4346}