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::{Arc, OnceLock, RwLock};
22use webui_protocol::{
23 web_ui_fragment::Fragment, ComponentAssetStylePreload, CssStrategy, StateProjectionMode,
24 WebUIFragmentRoute, WebUIProtocol,
25};
26
27fn partition_component_asset_style_preloads(
28 protocol: &WebUIProtocol,
29 preloads: &[ComponentAssetStylePreload],
30) -> (
31 Vec<ComponentAssetStylePreload>,
32 Vec<ComponentAssetStylePreload>,
33) {
34 let mut shadow = Vec::new();
35 let mut light = Vec::new();
36 for preload in preloads {
37 if protocol
38 .components
39 .get(&preload.root)
40 .is_some_and(|component| !component.uses_shadow_dom)
41 {
42 light.push(preload.clone());
43 } else {
44 shadow.push(preload.clone());
45 }
46 }
47 (shadow, light)
48}
49use crate::streaming::PreparedContinuationStatePlan;
50
51pub struct Protocol {
63 protocol: WebUIProtocol,
64 style_metadata_error: Option<String>,
65 css_strategy: webui_protocol::CssStrategy,
66 render_fragments: crate::RenderFragmentIndex,
69 component_asset_style_manifest: std::result::Result<String, String>,
70 component_asset_style_links: String,
71 component_index: HashMap<String, u32>,
72 style_resource_index: HashMap<String, u32>,
73 style_resources_requiring_escape: HashSet<String>,
74 component_reachability: OnceLock<ComponentReachabilityIndex>,
75 fragment_ids: Vec<Arc<str>>,
76 fragment_slots: HashMap<Arc<str>, u32>,
77 route_index: CompiledRouteIndex,
78 boundary_declarations: OnceLock<HashMap<u32, BoundaryDeclaration>>,
79 continuation_state_plans: OnceLock<Box<[OnceLock<PreparedContinuationStatePlan>]>>,
80 template_metadata_cache: RwLock<HashMap<String, Value>>,
81}
82
83pub(crate) struct BoundaryDeclaration {
88 pub(crate) owner: Arc<str>,
89 pub(crate) name: Arc<str>,
90}
91
92impl Protocol {
93 pub fn from_protobuf(bytes: &[u8]) -> std::result::Result<Self, webui_protocol::ProtocolError> {
99 let protocol = WebUIProtocol::from_protobuf(bytes)?;
100 if let Some(error) = Self::validate_component_style_metadata(&protocol) {
101 return Err(webui_protocol::ProtocolError::Validation(error));
102 }
103 Ok(Self::new_with_style_metadata(protocol, None))
104 }
105
106 #[must_use]
108 pub fn new(protocol: WebUIProtocol) -> Self {
109 let style_metadata_error = Self::validate_component_style_metadata(&protocol);
110 Self::new_with_style_metadata(protocol, style_metadata_error)
111 }
112
113 fn new_with_style_metadata(
114 mut protocol: WebUIProtocol,
115 style_metadata_error: Option<String>,
116 ) -> Self {
117 let css_strategy = protocol.css_strategy();
118 let component_asset_style_preloads =
119 std::mem::take(&mut protocol.component_asset_style_preloads);
120 let (shadow_preloads, light_preloads) =
121 partition_component_asset_style_preloads(&protocol, &component_asset_style_preloads);
122 let component_asset_style_links = if css_strategy == CssStrategy::Link {
123 crate::serialize_component_asset_style_links(&light_preloads)
124 } else {
125 String::new()
126 };
127 let component_asset_style_manifest = if css_strategy == CssStrategy::Link {
128 crate::serialize_component_asset_style_manifest(&shadow_preloads).map_err(|error| {
129 format!("failed to serialize component asset style metadata: {error}")
130 })
131 } else {
132 Ok(String::new())
133 };
134 let component_index = build_component_index(&protocol);
135 let style_resource_index = build_style_resource_index(&protocol);
136 let style_resources_requiring_escape = build_style_escape_resources(&protocol);
137 let route_index = CompiledRouteIndex::new(&protocol);
138 let mut fragment_ids: Vec<Arc<str>> = protocol
139 .fragments
140 .keys()
141 .map(|id| Arc::from(id.as_str()))
142 .collect();
143 fragment_ids.sort_unstable();
144 let mut fragment_slots = HashMap::with_capacity(fragment_ids.len());
145 for (slot, id) in fragment_ids.iter().enumerate() {
146 #[allow(clippy::cast_possible_truncation)]
148 fragment_slots.insert(Arc::clone(id), slot as u32);
149 }
150 let render_fragments =
154 crate::RenderFragmentIndex::new(&protocol, &fragment_ids, &fragment_slots);
155 Self {
156 protocol,
157 style_metadata_error,
158 css_strategy,
159 render_fragments,
160 component_asset_style_manifest,
161 component_asset_style_links,
162 component_index,
163 style_resource_index,
164 style_resources_requiring_escape,
165 component_reachability: OnceLock::new(),
166 fragment_ids,
167 fragment_slots,
168 route_index,
169 boundary_declarations: OnceLock::new(),
170 continuation_state_plans: OnceLock::new(),
171 template_metadata_cache: RwLock::new(HashMap::new()),
172 }
173 }
174
175 pub(crate) fn protocol(&self) -> &WebUIProtocol {
176 &self.protocol
177 }
178
179 pub(crate) fn ensure_style_metadata(&self) -> Result<(), HandlerError> {
180 self.style_metadata_error
181 .as_ref()
182 .map_or(Ok(()), |error| Err(HandlerError::Invariant(error.clone())))
183 }
184
185 pub(crate) fn css_strategy(&self) -> webui_protocol::CssStrategy {
186 self.css_strategy
187 }
188
189 pub(crate) fn render_fragments(&self) -> &crate::RenderFragmentIndex {
191 &self.render_fragments
192 }
193
194 pub(crate) fn component_asset_style_manifest(&self) -> Result<&str, HandlerError> {
195 match &self.component_asset_style_manifest {
196 Ok(manifest) => Ok(manifest),
197 Err(message) => Err(HandlerError::Rendering(message.clone())),
198 }
199 }
200
201 pub(crate) fn component_asset_style_links(&self) -> &str {
202 &self.component_asset_style_links
203 }
204
205 pub(crate) fn component_index(&self) -> &HashMap<String, u32> {
206 &self.component_index
207 }
208
209 pub(crate) fn style_resource_index(&self) -> &HashMap<String, u32> {
210 if self.style_resource_index.is_empty() {
211 &self.component_index
212 } else {
213 &self.style_resource_index
214 }
215 }
216
217 pub(crate) fn style_resources_requiring_escape(&self) -> &HashSet<String> {
218 &self.style_resources_requiring_escape
219 }
220
221 pub(crate) fn component_reachability(&self) -> &ComponentReachabilityIndex {
222 self.component_reachability
223 .get_or_init(|| ComponentReachabilityIndex::new(&self.protocol, &self.component_index))
224 }
225
226 pub(crate) fn route_index(&self) -> &CompiledRouteIndex {
227 &self.route_index
228 }
229
230 pub(crate) fn fragment_slot(&self, id: &str) -> Option<u32> {
235 self.fragment_slots.get(id).copied()
236 }
237
238 pub(crate) fn fragment_id(&self, slot: u32) -> Option<&str> {
240 usize::try_from(slot)
241 .ok()
242 .and_then(|slot| self.fragment_ids.get(slot))
243 .map(Arc::as_ref)
244 }
245
246 pub(crate) fn boundary_declaration(&self, declaration_id: u32) -> Option<&BoundaryDeclaration> {
252 self.boundary_declarations
253 .get_or_init(|| build_boundary_declarations(&self.protocol))
254 .get(&declaration_id)
255 }
256
257 pub(crate) fn continuation_state_plan(
269 &self,
270 entry_id: &str,
271 ) -> Result<&PreparedContinuationStatePlan, HandlerError> {
272 let slot = self
273 .fragment_slot(entry_id)
274 .ok_or_else(|| HandlerError::MissingFragment(entry_id.to_string()))?;
275 let index = usize::try_from(slot).map_err(|_| {
276 HandlerError::Invariant("continuation plan slot does not fit usize".to_string())
277 })?;
278 let plans = self.continuation_state_plans.get_or_init(|| {
279 (0..self.fragment_ids.len())
280 .map(|_| OnceLock::new())
281 .collect()
282 });
283 let cell = plans
284 .get(index)
285 .ok_or_else(|| HandlerError::MissingFragment(entry_id.to_string()))?;
286 Ok(cell.get_or_init(|| PreparedContinuationStatePlan::new(&self.protocol, entry_id)))
287 }
288
289 #[must_use]
291 pub fn tokens(&self) -> &[String] {
292 &self.protocol.tokens
293 }
294
295 #[doc(hidden)]
302 #[must_use]
303 pub fn has_fragment(&self, id: &str) -> bool {
304 self.render_fragments.index(id).is_some()
305 }
306
307 pub fn render_partial_json(
313 &self,
314 state_json: &str,
315 entry_id: &str,
316 request_path: &str,
317 inventory_hex: &str,
318 ) -> Result<String, HandlerError> {
319 self.ensure_style_metadata()?;
320 let mut index = self.request_index();
321 let (response, state_selection, _) = render_partial_indexed_with_state(
322 self.protocol(),
323 entry_id,
324 request_path,
325 inventory_hex,
326 &mut index,
327 )?;
328 serialize_partial_response(response, state_json, &state_selection)
329 }
330
331 pub fn render_partial(
337 &self,
338 state: Value,
339 entry_id: &str,
340 request_path: &str,
341 inventory_hex: &str,
342 ) -> Result<String, HandlerError> {
343 let response = self.prepare_partial(state, entry_id, request_path, inventory_hex)?;
344 serde_json::to_string(&response)
345 .map_err(|error| partial_serialize_error(&error.to_string()))
346 }
347
348 #[must_use = "handle preparation errors before serializing the navigation response"]
360 pub fn prepare_partial(
361 &self,
362 state: Value,
363 entry_id: &str,
364 request_path: &str,
365 inventory_hex: &str,
366 ) -> Result<PartialNavigation, HandlerError> {
367 self.ensure_style_metadata()?;
368 let mut index = self.request_index();
369 let (response, state_selection, matched) = render_partial_indexed_with_state(
370 self.protocol(),
371 entry_id,
372 request_path,
373 inventory_hex,
374 &mut index,
375 )?;
376 let state = select_owned_state(state, &state_selection);
377 let response = PartialResponseWithState::new(response, state)?;
378 Ok(PartialNavigation { response, matched })
379 }
380
381 #[must_use]
384 pub fn matches_route(&self, entry_id: &str, request_path: &str) -> bool {
385 let chain =
386 collect_route_chain_plan(self.protocol(), entry_id, request_path, self.route_index());
387 route_chain_matches(&chain.entries, request_path)
388 }
389
390 pub fn render_component_templates(
392 &self,
393 component_tags: &[&str],
394 inventory_hex: &str,
395 ) -> Result<Value, HandlerError> {
396 self.ensure_style_metadata()?;
397 let mut index = self.request_index();
398 render_component_templates_indexed(
399 self.protocol(),
400 component_tags,
401 inventory_hex,
402 &mut index,
403 )
404 }
405
406 fn validate_component_style_metadata(protocol: &WebUIProtocol) -> Option<String> {
407 if protocol.style_closures.is_empty() {
408 let requires_closures = protocol.components.iter().any(|(tag, component)| {
409 protocol.component_style_resource(tag).is_some() || component.uses_shadow_dom
410 });
411 if requires_closures {
412 return Some(
413 "component style closure metadata is required by this protocol".to_string(),
414 );
415 }
416 return None;
417 }
418 let mut seen_resources = HashSet::new();
419 for (root, closure) in &protocol.style_closures {
420 seen_resources.clear();
421 seen_resources.reserve(closure.component_tags.len());
422 for tag in &closure.component_tags {
423 if !seen_resources.insert(tag.as_str()) {
424 return Some(format!(
425 "component style closure `{root}` contains duplicate resource `{tag}`"
426 ));
427 }
428 if protocol.component_style_resource(tag).is_none() {
429 return Some(format!(
430 "component style closure `{root}` references missing resource `{tag}`"
431 ));
432 }
433 }
434 }
435 for (fragment_id, fragments) in &protocol.fragments {
436 for fragment in &fragments.fragments {
437 let Some(Fragment::Signal(signal)) = fragment.fragment.as_ref() else {
438 continue;
439 };
440 let Some(root) = crate::structural_signal_value(signal)
441 .and_then(|value| value.strip_prefix("shadow_styles:"))
442 else {
443 continue;
444 };
445 let Some(component) = protocol.components.get(fragment_id) else {
446 return Some(format!(
447 "Shadow style hook `{root}` references unknown component `{fragment_id}`"
448 ));
449 };
450 if root != fragment_id.as_str() || !component.uses_shadow_dom {
451 return Some(format!(
452 "Shadow style hook `{root}` does not match component fragment `{fragment_id}`"
453 ));
454 }
455 }
456 }
457 for (tag, component) in &protocol.components {
458 if !component.uses_shadow_dom {
459 continue;
460 }
461 let Some(fragments) = protocol.fragments.get(tag) else {
462 continue;
463 };
464 if !protocol.style_closures.contains_key(tag) {
465 return Some(format!(
466 "component style closure metadata is missing Shadow root `{tag}`"
467 ));
468 }
469 let hook_count = fragments
470 .fragments
471 .iter()
472 .filter(|fragment| {
473 matches!(
474 fragment.fragment.as_ref(),
475 Some(Fragment::Signal(signal))
476 if crate::structural_signal_value(signal)
477 .and_then(|value| value.strip_prefix("shadow_styles:"))
478 == Some(tag.as_str())
479 )
480 })
481 .count();
482 if hook_count == 0 {
483 return Some(format!(
484 "Shadow component `{tag}` is missing its compiler style insertion hook"
485 ));
486 }
487 if hook_count > 1 {
488 return Some(format!(
489 "Shadow component `{tag}` emitted more than one style insertion hook"
490 ));
491 }
492 }
493 None
494 }
495
496 #[cfg(test)]
497 fn render_partial_metadata(
498 &self,
499 entry_id: &str,
500 request_path: &str,
501 inventory_hex: &str,
502 ) -> Result<Value, HandlerError> {
503 self.ensure_style_metadata()?;
504 let mut index = self.request_index();
505 render_partial_indexed(
506 self.protocol(),
507 entry_id,
508 request_path,
509 inventory_hex,
510 &mut index,
511 )
512 }
513
514 fn request_index(&self) -> RequestProtocolIndex<'_> {
515 RequestProtocolIndex {
516 component_index: &self.component_index,
517 route_index: &self.route_index,
518 template_metadata_cache: TemplateMetadataCache::Shared(&self.template_metadata_cache),
519 }
520 }
521}
522
523#[cfg(test)]
529struct ProtocolIndex {
530 pub component_index: HashMap<String, u32>,
532 pub route_index: CompiledRouteIndex,
534 template_metadata_cache: HashMap<String, Value>,
536}
537
538struct ComponentAssets {
539 component_styles: Value,
540 templates: serde_json::Map<String, Value>,
541 functions: serde_json::Map<String, Value>,
542}
543
544pub(crate) fn collect_component_styles<'a>(
547 protocol: &WebUIProtocol,
548 roots: impl IntoIterator<Item = &'a str>,
549) -> Result<Value, HandlerError> {
550 collect_component_styles_inner(protocol, roots, None)
551}
552
553#[derive(Clone, Copy)]
554struct StyleInventoryView<'a> {
555 bits: &'a [u8],
556 index: &'a HashMap<String, u32>,
557 chunks_are_indexed: bool,
558}
559
560impl StyleInventoryView<'_> {
561 fn contains_component(self, tag: &str) -> bool {
562 self.index
563 .get(tag)
564 .is_some_and(|&index| has_component(self.bits, index))
565 }
566
567 fn contains_chunk(self, name: &str) -> bool {
568 self.chunks_are_indexed && self.contains_component(name)
569 }
570}
571
572#[cfg(test)]
573pub(crate) fn collect_component_style_delta<'a>(
574 protocol: &WebUIProtocol,
575 roots: impl IntoIterator<Item = &'a str>,
576 style_inventory: &[u8],
577 style_resource_index: &HashMap<String, u32>,
578) -> Result<Value, HandlerError> {
579 collect_component_styles_inner(
580 protocol,
581 roots,
582 Some(StyleInventoryView {
583 bits: style_inventory,
584 index: style_resource_index,
585 chunks_are_indexed: true,
586 }),
587 )
588}
589
590pub(crate) struct BorrowedComponentStyleDelta<'a> {
591 strategy: webui_protocol::CssStrategy,
592 resources: Vec<BorrowedStyleResource<'a>>,
593 closures: Vec<BorrowedStyleClosure<'a>>,
594 ordered: Vec<&'a str>,
595}
596
597struct BorrowedStyleResource<'a> {
598 name: &'a str,
599 resource: &'a str,
600 members: Option<&'a [String]>,
601}
602
603struct BorrowedStyleClosure<'a> {
604 root: &'a str,
605 start: usize,
606 end: usize,
607}
608
609impl BorrowedComponentStyleDelta<'_> {
610 pub(crate) fn resource_names(&self) -> impl Iterator<Item = &str> {
611 self.resources.iter().map(|resource| resource.name)
612 }
613}
614
615impl Serialize for BorrowedComponentStyleDelta<'_> {
616 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
617 where
618 S: serde::Serializer,
619 {
620 let mut payload = serializer.serialize_map(Some(4))?;
621 payload.serialize_entry(
622 "closures",
623 &BorrowedStyleClosures {
624 closures: &self.closures,
625 ordered: &self.ordered,
626 },
627 )?;
628 payload.serialize_entry(
629 "resources",
630 &BorrowedStyleResources {
631 strategy: self.strategy,
632 resources: &self.resources,
633 },
634 )?;
635 payload.serialize_entry("strategy", self.strategy.wire_name())?;
636 payload.serialize_entry("version", &1)?;
637 payload.end()
638 }
639}
640
641struct BorrowedStyleResources<'a> {
642 strategy: webui_protocol::CssStrategy,
643 resources: &'a [BorrowedStyleResource<'a>],
644}
645
646impl Serialize for BorrowedStyleResources<'_> {
647 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
648 where
649 S: serde::Serializer,
650 {
651 let mut resources = serializer.serialize_map(Some(self.resources.len()))?;
652 for resource in self.resources {
653 resources.serialize_entry(
654 resource.name,
655 &BorrowedStyleResourceValue {
656 strategy: self.strategy,
657 resource,
658 },
659 )?;
660 }
661 resources.end()
662 }
663}
664
665struct BorrowedStyleResourceValue<'a> {
666 strategy: webui_protocol::CssStrategy,
667 resource: &'a BorrowedStyleResource<'a>,
668}
669
670impl Serialize for BorrowedStyleResourceValue<'_> {
671 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
672 where
673 S: serde::Serializer,
674 {
675 let field_count = match (self.strategy, self.resource.members.is_some()) {
676 (webui_protocol::CssStrategy::Module, true) => 4,
677 (webui_protocol::CssStrategy::Module, false) | (_, true) => 3,
678 (_, false) => 2,
679 };
680 let mut resource = serializer.serialize_map(Some(field_count))?;
681 match self.strategy {
682 webui_protocol::CssStrategy::Link => {
683 resource.serialize_entry("href", self.resource.resource)?;
684 resource.serialize_entry("kind", "link")?;
685 if let Some(members) = self.resource.members {
686 resource.serialize_entry("members", members)?;
687 }
688 }
689 webui_protocol::CssStrategy::Style => {
690 resource.serialize_entry("css", self.resource.resource)?;
691 resource.serialize_entry("kind", "style")?;
692 if let Some(members) = self.resource.members {
693 resource.serialize_entry("members", members)?;
694 }
695 }
696 webui_protocol::CssStrategy::Module => {
697 resource.serialize_entry("css", self.resource.resource)?;
698 resource.serialize_entry("kind", "module")?;
699 if let Some(members) = self.resource.members {
700 resource.serialize_entry("members", members)?;
701 }
702 resource.serialize_entry("specifier", self.resource.name)?;
703 }
704 }
705 resource.end()
706 }
707}
708
709struct BorrowedStyleClosures<'a> {
710 closures: &'a [BorrowedStyleClosure<'a>],
711 ordered: &'a [&'a str],
712}
713
714impl Serialize for BorrowedStyleClosures<'_> {
715 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
716 where
717 S: serde::Serializer,
718 {
719 let mut closures = serializer.serialize_map(Some(self.closures.len()))?;
720 for closure in self.closures {
721 closures.serialize_entry(closure.root, &self.ordered[closure.start..closure.end])?;
722 }
723 closures.end()
724 }
725}
726
727pub(crate) fn collect_borrowed_component_style_delta<'a>(
728 protocol: &'a WebUIProtocol,
729 roots: impl IntoIterator<Item = &'a str>,
730 style_inventory: &[u8],
731 style_resource_index: &HashMap<String, u32>,
732 chunk_index: &HashMap<&str, u32>,
733) -> Result<BorrowedComponentStyleDelta<'a>, HandlerError> {
734 if protocol.style_closures.is_empty() {
735 if protocol
736 .components
737 .keys()
738 .any(|tag| protocol.component_style_resource(tag).is_some())
739 {
740 return Err(HandlerError::Invariant(
741 "component style closure metadata is required by this protocol".to_string(),
742 ));
743 }
744 return Ok(BorrowedComponentStyleDelta {
745 strategy: protocol.css_strategy(),
746 resources: Vec::new(),
747 closures: Vec::new(),
748 ordered: Vec::new(),
749 });
750 }
751
752 let mut seen_roots = HashSet::new();
753 let roots: Vec<&str> = roots
754 .into_iter()
755 .filter(|root| seen_roots.insert(*root))
756 .collect();
757 let client_inventory = StyleInventoryView {
758 bits: style_inventory,
759 index: style_resource_index,
760 chunks_are_indexed: true,
761 };
762 let covered_components = covered_components(protocol, &roots, Some(client_inventory));
763 let unit_capacity = roots
764 .iter()
765 .filter_map(|root| protocol.style_closures.get(*root))
766 .map(WebUIProtocol::style_closure_unit_count)
767 .sum();
768 let mut resources = Vec::with_capacity(unit_capacity);
769 let mut closures = Vec::with_capacity(roots.len());
770 let mut ordered = Vec::with_capacity(unit_capacity);
771 let mut emitted = HashSet::with_capacity(unit_capacity);
772 let mut serialized_resources = HashSet::with_capacity(unit_capacity);
773
774 for root in roots {
775 let Some(closure) = protocol.style_closures.get(root) else {
776 if !protocol.fragments.contains_key(root) && !protocol.components.contains_key(root) {
777 continue;
778 }
779 return Err(HandlerError::Invariant(format!(
780 "component style closure metadata is missing root `{root}`"
781 )));
782 };
783 if closure.style_chunks.is_empty()
784 && !closure.component_tags.is_empty()
785 && closure
786 .component_tags
787 .iter()
788 .all(|tag| covered_components.contains(tag.as_str()))
789 {
790 continue;
791 }
792
793 let start = ordered.len();
794 emitted.clear();
795 let unit_count = WebUIProtocol::style_closure_unit_count(closure);
796 for position in 0..unit_count {
797 let Some(unit) = protocol.style_closure_unit(closure, chunk_index, position) else {
798 continue;
799 };
800 let name = unit.name;
801 let resource = unit.resource.ok_or_else(|| match unit.chunk {
802 Some(index) => HandlerError::Invariant(format!(
803 "component style closure `{root}` references missing style chunk {index}"
804 )),
805 None => HandlerError::Invariant(format!(
806 "component style closure `{root}` references missing resource `{name}`"
807 )),
808 })?;
809 if !emitted.insert(name) {
810 continue;
811 }
812
813 let (client_has_it, members) = match unit.chunk {
814 Some(index) => (
815 client_inventory.contains_chunk(name),
816 protocol.style_chunk_members(index),
817 ),
818 None => (client_inventory.contains_component(name), None),
819 };
820 if !client_has_it && serialized_resources.insert(name) {
821 resources.push(BorrowedStyleResource {
822 name,
823 resource,
824 members: members.filter(|members| members.len() > 1),
825 });
826 }
827 ordered.push(name);
828 }
829 closures.push(BorrowedStyleClosure {
830 root,
831 start,
832 end: ordered.len(),
833 });
834 }
835
836 resources.sort_unstable_by_key(|resource| resource.name);
837 closures.sort_unstable_by_key(|closure| closure.root);
838 Ok(BorrowedComponentStyleDelta {
839 strategy: protocol.css_strategy(),
840 resources,
841 closures,
842 ordered,
843 })
844}
845
846fn collect_component_styles_for_inventory<'a>(
847 protocol: &WebUIProtocol,
848 roots: impl IntoIterator<Item = &'a str>,
849 component_inventory: &[u8],
850 component_index: &HashMap<String, u32>,
851) -> Result<Value, HandlerError> {
852 collect_component_styles_inner(
853 protocol,
854 roots,
855 Some(StyleInventoryView {
856 bits: component_inventory,
857 index: component_index,
858 chunks_are_indexed: false,
859 }),
860 )
861}
862
863fn collect_component_styles_inner<'a>(
864 protocol: &WebUIProtocol,
865 roots: impl IntoIterator<Item = &'a str>,
866 client_inventory: Option<StyleInventoryView<'_>>,
867) -> Result<Value, HandlerError> {
868 if protocol.style_closures.is_empty() {
869 if protocol
870 .components
871 .keys()
872 .any(|tag| protocol.component_style_resource(tag).is_some())
873 {
874 return Err(HandlerError::Invariant(
875 "component style closure metadata is required by this protocol".to_string(),
876 ));
877 }
878 return Ok(component_styles_payload(
879 protocol.css_strategy(),
880 serde_json::Map::new(),
881 serde_json::Map::new(),
882 ));
883 }
884
885 let mut resources = serde_json::Map::new();
886 let mut closures = serde_json::Map::new();
887 let mut seen_roots = HashSet::new();
888 let roots: Vec<&str> = roots
889 .into_iter()
890 .filter(|root| seen_roots.insert(*root))
891 .collect();
892 let covered_components = covered_components(protocol, &roots, client_inventory);
893 let chunk_index = protocol.style_chunk_index();
894 let mut emitted = HashSet::new();
895
896 for root in roots {
897 let Some(closure) = protocol.style_closures.get(root) else {
898 if !protocol.fragments.contains_key(root) && !protocol.components.contains_key(root) {
899 continue;
900 }
901 return Err(HandlerError::Invariant(format!(
902 "component style closure metadata is missing root `{root}`"
903 )));
904 };
905 if closure.style_chunks.is_empty()
906 && !closure.component_tags.is_empty()
907 && closure
908 .component_tags
909 .iter()
910 .all(|tag| covered_components.contains(tag.as_str()))
911 {
912 continue;
913 }
914 let unit_count = WebUIProtocol::style_closure_unit_count(closure);
915 let mut ordered = Vec::with_capacity(unit_count);
916 emitted.clear();
917 for position in 0..unit_count {
918 let Some(unit) = protocol.style_closure_unit(closure, &chunk_index, position) else {
925 continue;
926 };
927 let name = unit.name;
928 let resource = unit.resource.ok_or_else(|| match unit.chunk {
929 Some(index) => HandlerError::Invariant(format!(
930 "component style closure `{root}` references missing style chunk {index}"
931 )),
932 None => HandlerError::Invariant(format!(
933 "component style closure `{root}` references missing resource `{name}`"
934 )),
935 })?;
936 let (client_has_it, members) = match unit.chunk {
937 Some(index) => (
938 client_inventory.is_some_and(|inventory| inventory.contains_chunk(name)),
939 Some(protocol.style_chunk_members(index).unwrap_or_default()),
940 ),
941 None => (
942 client_inventory.is_some_and(|inventory| inventory.contains_component(name)),
943 None,
944 ),
945 };
946 if !emitted.insert(name) {
948 continue;
949 }
950 if !client_has_it && !resources.contains_key(name) {
951 let mut entry = serde_json::Map::new();
952 match protocol.css_strategy() {
953 webui_protocol::CssStrategy::Link => {
954 entry.insert("kind".into(), Value::String("link".into()));
955 entry.insert("href".into(), Value::String(resource.to_owned()));
956 }
957 webui_protocol::CssStrategy::Style => {
958 entry.insert("kind".into(), Value::String("style".into()));
959 entry.insert("css".into(), Value::String(resource.to_owned()));
960 }
961 webui_protocol::CssStrategy::Module => {
962 entry.insert("kind".into(), Value::String("module".into()));
963 entry.insert("specifier".into(), Value::String(name.to_owned()));
964 entry.insert("css".into(), Value::String(resource.to_owned()));
965 }
966 }
967 if let Some(members) = members.filter(|members| members.len() > 1) {
968 entry.insert(
969 "members".into(),
970 Value::Array(members.iter().cloned().map(Value::String).collect()),
971 );
972 }
973 resources.insert(name.to_owned(), Value::Object(entry));
974 }
975 ordered.push(Value::String(name.to_owned()));
976 }
977 closures.insert(root.to_owned(), Value::Array(ordered));
978 }
979
980 Ok(component_styles_payload(
981 protocol.css_strategy(),
982 resources,
983 closures,
984 ))
985}
986
987fn covered_components<'a>(
988 protocol: &'a WebUIProtocol,
989 roots: &[&str],
990 inventory: Option<StyleInventoryView<'_>>,
991) -> HashSet<&'a str> {
992 let mut covered = HashSet::with_capacity(protocol.components.len());
993 for root in roots {
994 let Some(closure) = protocol.style_closures.get(*root) else {
995 continue;
996 };
997 for index in &closure.style_chunks {
998 if let Some(chunk) = usize::try_from(*index)
999 .ok()
1000 .and_then(|index| protocol.style_chunks.get(index))
1001 {
1002 covered.extend(chunk.component_tags.iter().map(String::as_str));
1003 }
1004 }
1005 }
1006
1007 if let Some(inventory) = inventory {
1008 for chunk in &protocol.style_chunks {
1009 if inventory.contains_chunk(&chunk.name) {
1010 covered.extend(chunk.component_tags.iter().map(String::as_str));
1011 }
1012 }
1013 }
1014 covered
1015}
1016
1017fn component_styles_payload(
1018 strategy: webui_protocol::CssStrategy,
1019 resources: serde_json::Map<String, Value>,
1020 closures: serde_json::Map<String, Value>,
1021) -> Value {
1022 let strategy = strategy.wire_name();
1023 let mut payload = serde_json::Map::new();
1024 payload.insert("version".into(), Value::from(1));
1025 payload.insert("strategy".into(), Value::String(strategy.into()));
1026 payload.insert("resources".into(), Value::Object(resources));
1027 payload.insert("closures".into(), Value::Object(closures));
1028 Value::Object(payload)
1029}
1030
1031#[cfg(test)]
1032impl ProtocolIndex {
1033 #[must_use]
1035 pub(crate) fn new(protocol: &WebUIProtocol) -> Self {
1036 Self {
1037 component_index: build_component_index(protocol),
1038 route_index: CompiledRouteIndex::new(protocol),
1039 template_metadata_cache: HashMap::new(),
1040 }
1041 }
1042
1043 fn request_index(&mut self) -> RequestProtocolIndex<'_> {
1044 RequestProtocolIndex {
1045 component_index: &self.component_index,
1046 route_index: &self.route_index,
1047 template_metadata_cache: TemplateMetadataCache::Exclusive(
1048 &mut self.template_metadata_cache,
1049 ),
1050 }
1051 }
1052}
1053
1054enum TemplateMetadataCache<'a> {
1055 #[cfg(test)]
1056 Exclusive(&'a mut HashMap<String, Value>),
1057 Shared(&'a RwLock<HashMap<String, Value>>),
1058}
1059
1060struct RequestProtocolIndex<'a> {
1061 component_index: &'a HashMap<String, u32>,
1062 route_index: &'a CompiledRouteIndex,
1063 template_metadata_cache: TemplateMetadataCache<'a>,
1064}
1065
1066fn build_boundary_declarations(protocol: &WebUIProtocol) -> HashMap<u32, BoundaryDeclaration> {
1072 let mut declarations = HashMap::new();
1073 for list in protocol.fragments.values() {
1074 for fragment in &list.fragments {
1075 let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() else {
1076 continue;
1077 };
1078 if boundary.phase() != webui_protocol::BoundaryPhase::Start {
1079 continue;
1080 }
1081 declarations.insert(
1082 boundary.declaration_id,
1083 BoundaryDeclaration {
1084 owner: Arc::from(boundary.owner_fragment_id.as_str()),
1085 name: Arc::from(boundary.name.as_str()),
1086 },
1087 );
1088 }
1089 }
1090 declarations
1091}
1092
1093pub(crate) fn build_component_index(protocol: &WebUIProtocol) -> HashMap<String, u32> {
1100 let mut sorted: Vec<&String> = protocol
1101 .fragments
1102 .keys()
1103 .filter(|key| key.contains('-'))
1104 .collect();
1105 sorted.sort_unstable();
1106 let mut index = HashMap::with_capacity(sorted.len());
1107 for (i, name) in sorted.into_iter().enumerate() {
1108 #[allow(clippy::cast_possible_truncation)]
1110 let idx = i as u32;
1111 index.insert(name.clone(), idx);
1112 }
1113 index
1114}
1115
1116fn build_style_resource_index(protocol: &WebUIProtocol) -> HashMap<String, u32> {
1118 if protocol.style_chunks.is_empty() {
1119 return HashMap::new();
1120 }
1121 let mut sorted: Vec<&str> = protocol
1122 .components
1123 .keys()
1124 .filter(|tag| protocol.component_style_resource(tag).is_some())
1125 .map(String::as_str)
1126 .chain(
1127 protocol
1128 .style_chunks
1129 .iter()
1130 .map(|chunk| chunk.name.as_str()),
1131 )
1132 .collect();
1133 sorted.sort_unstable();
1134 sorted.dedup();
1135 let mut index = HashMap::with_capacity(sorted.len());
1136 for (position, name) in sorted.into_iter().enumerate() {
1137 #[allow(clippy::cast_possible_truncation)]
1139 let position = position as u32;
1140 index.insert(name.to_string(), position);
1141 }
1142 index
1143}
1144
1145fn build_style_escape_resources(protocol: &WebUIProtocol) -> HashSet<String> {
1146 if protocol.css_strategy() == CssStrategy::Link {
1147 return HashSet::new();
1148 }
1149
1150 let mut resources = HashSet::new();
1151 for (name, component) in &protocol.components {
1152 if crate::html_encode::style_text_needs_escape(&component.css) {
1153 resources.insert(name.clone());
1154 }
1155 }
1156 for chunk in &protocol.style_chunks {
1157 if crate::html_encode::style_text_needs_escape(&chunk.css) {
1158 resources.insert(chunk.name.clone());
1159 }
1160 }
1161 resources
1162}
1163
1164pub(crate) struct ComponentReachabilityIndex {
1175 names: Vec<String>,
1176 dependencies: Vec<Box<[u32]>>,
1177 route_dependent: Vec<bool>,
1178 hydration_keys: Vec<Box<str>>,
1179 hydration_key_ids: Vec<u32>,
1180 hydration_runs: Vec<HydrationRun>,
1181}
1182
1183#[derive(Clone, Copy)]
1189struct HydrationRun {
1190 start: u32,
1191 len: u32,
1192}
1193
1194impl HydrationRun {
1195 const FULL_STATE: Self = Self {
1196 start: 0,
1197 len: u32::MAX,
1198 };
1199
1200 const fn requires_full_state(self) -> bool {
1201 self.len == u32::MAX
1202 }
1203}
1204
1205impl ComponentReachabilityIndex {
1206 fn new(protocol: &WebUIProtocol, component_index: &HashMap<String, u32>) -> Self {
1207 let mut names: Vec<String> = component_index.keys().cloned().collect();
1208 names.sort_unstable();
1209
1210 let mut dependencies = Vec::with_capacity(names.len());
1211 let mut route_dependent = Vec::with_capacity(names.len());
1212 for name in &names {
1213 let (direct, has_route) =
1214 collect_direct_component_dependencies(protocol, name, component_index);
1215 dependencies.push(direct.into_boxed_slice());
1216 route_dependent.push(has_route);
1217 }
1218 propagate_route_dependencies(&dependencies, &mut route_dependent);
1219 let (hydration_keys, hydration_key_ids, hydration_runs) =
1220 intern_hydration_projections(protocol, &names);
1221
1222 Self {
1223 names,
1224 dependencies,
1225 route_dependent,
1226 hydration_keys,
1227 hydration_key_ids,
1228 hydration_runs,
1229 }
1230 }
1231
1232 pub(crate) fn name(&self, index: u32) -> Option<&str> {
1233 self.names.get(index as usize).map(String::as_str)
1234 }
1235
1236 pub(crate) fn dependencies(&self, index: u32) -> Option<&[u32]> {
1237 self.dependencies.get(index as usize).map(Box::as_ref)
1238 }
1239
1240 pub(crate) fn is_route_dependent(&self, index: u32) -> Option<bool> {
1241 self.route_dependent.get(index as usize).copied()
1242 }
1243
1244 pub(crate) fn requires_expansion(&self, index: u32) -> Option<bool> {
1245 Some(self.is_route_dependent(index)? || !self.dependencies.get(index as usize)?.is_empty())
1246 }
1247
1248 pub(crate) fn hydration_key(&self, id: u32) -> Option<&str> {
1250 self.hydration_keys.get(id as usize).map(Box::as_ref)
1251 }
1252
1253 pub(crate) fn extend_hydration_keys(&self, index: u32, ids: &mut Vec<u32>) -> bool {
1258 let Some(run) = self.hydration_runs.get(index as usize).copied() else {
1259 return true;
1260 };
1261 if run.requires_full_state() {
1262 return true;
1263 }
1264 let start = run.start as usize;
1265 let end = start.saturating_add(run.len as usize);
1266 match self.hydration_key_ids.get(start..end) {
1267 Some(run) => ids.extend_from_slice(run),
1268 None => return true,
1269 }
1270 false
1271 }
1272}
1273
1274fn intern_hydration_projections(
1277 protocol: &WebUIProtocol,
1278 names: &[String],
1279) -> (Vec<Box<str>>, Vec<u32>, Vec<HydrationRun>) {
1280 let mut distinct: Vec<&str> = Vec::new();
1281 for name in names {
1282 if let Some(component) = protocol.components.get(name) {
1283 distinct.extend(component.hydration_keys.iter().map(String::as_str));
1284 }
1285 }
1286 distinct.sort_unstable();
1287 distinct.dedup();
1288 let keys: Vec<Box<str>> = distinct.iter().map(|key| Box::from(*key)).collect();
1289
1290 let mut key_ids = Vec::new();
1291 let mut runs = Vec::with_capacity(names.len());
1292 for name in names {
1293 let Some(component) = protocol.components.get(name) else {
1294 runs.push(HydrationRun::FULL_STATE);
1297 continue;
1298 };
1299 let mode = component.hydration_mode;
1300 let component_keys = &component.hydration_keys;
1301 let projects_keys = mode == StateProjectionMode::Keys as i32
1302 || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty());
1303 if mode == StateProjectionMode::All as i32
1304 || (!projects_keys && mode != StateProjectionMode::None as i32)
1305 {
1306 runs.push(HydrationRun::FULL_STATE);
1307 continue;
1308 }
1309 let start = key_ids.len();
1310 if projects_keys {
1311 for key in component_keys {
1312 if let Ok(position) = distinct.binary_search(&key.as_str()) {
1313 if let Ok(id) = u32::try_from(position) {
1314 key_ids.push(id);
1315 }
1316 }
1317 }
1318 }
1319 match (u32::try_from(start), u32::try_from(key_ids.len() - start)) {
1320 (Ok(start), Ok(len)) if len != u32::MAX => runs.push(HydrationRun { start, len }),
1321 _ => runs.push(HydrationRun::FULL_STATE),
1322 }
1323 }
1324 (keys, key_ids, runs)
1325}
1326
1327enum ComponentDependencyWork<'a> {
1328 Fragment(&'a str),
1329 Component(u32),
1330}
1331
1332fn collect_direct_component_dependencies(
1333 protocol: &WebUIProtocol,
1334 root: &str,
1335 component_index: &HashMap<String, u32>,
1336) -> (Vec<u32>, bool) {
1337 let mut work = vec![ComponentDependencyWork::Fragment(root)];
1338 let mut visited_fragments = HashSet::new();
1339 let mut seen_components = HashSet::new();
1340 let mut dependencies = Vec::new();
1341 let mut has_route = false;
1342
1343 while let Some(item) = work.pop() {
1344 match item {
1345 ComponentDependencyWork::Component(index) => {
1346 if seen_components.insert(index) {
1347 dependencies.push(index);
1348 }
1349 }
1350 ComponentDependencyWork::Fragment(id) => {
1351 if !visited_fragments.insert(id) {
1352 continue;
1353 }
1354 let Some(list) = protocol.fragments.get(id) else {
1355 continue;
1356 };
1357 for fragment in list.fragments.iter().rev() {
1358 match fragment.fragment.as_ref() {
1359 Some(Fragment::Component(component)) => {
1360 if let Some(&index) = component_index.get(&component.fragment_id) {
1361 work.push(ComponentDependencyWork::Component(index));
1362 }
1363 }
1364 Some(Fragment::ForLoop(for_loop)) => work.push(
1365 ComponentDependencyWork::Fragment(for_loop.fragment_id.as_str()),
1366 ),
1367 Some(Fragment::IfCond(if_cond)) => work.push(
1368 ComponentDependencyWork::Fragment(if_cond.fragment_id.as_str()),
1369 ),
1370 Some(Fragment::Attribute(attribute)) if !attribute.template.is_empty() => {
1371 work.push(ComponentDependencyWork::Fragment(
1372 attribute.template.as_str(),
1373 ));
1374 }
1375 Some(Fragment::Route(_)) => has_route = true,
1376 _ => {}
1377 }
1378 }
1379 }
1380 }
1381 }
1382
1383 (dependencies, has_route)
1384}
1385
1386fn propagate_route_dependencies(dependencies: &[Box<[u32]>], route_dependent: &mut [bool]) {
1387 let mut reverse = vec![Vec::new(); dependencies.len()];
1388 for (parent, children) in dependencies.iter().enumerate() {
1389 for &child in children.iter() {
1390 if let Some(parents) = reverse.get_mut(child as usize) {
1391 #[allow(clippy::cast_possible_truncation)]
1392 parents.push(parent as u32);
1393 }
1394 }
1395 }
1396
1397 let mut pending = Vec::new();
1398 for (index, &depends_on_route) in route_dependent.iter().enumerate() {
1399 if depends_on_route {
1400 #[allow(clippy::cast_possible_truncation)]
1401 pending.push(index as u32);
1402 }
1403 }
1404 while let Some(index) = pending.pop() {
1405 let Some(parents) = reverse.get(index as usize) else {
1406 continue;
1407 };
1408 for &parent in parents {
1409 let Some(depends_on_route) = route_dependent.get_mut(parent as usize) else {
1410 continue;
1411 };
1412 if !*depends_on_route {
1413 *depends_on_route = true;
1414 pending.push(parent);
1415 }
1416 }
1417 }
1418}
1419
1420fn has_component(inventory: &[u8], index: u32) -> bool {
1422 let byte_idx = (index / 8) as usize;
1423 let bit_idx = index % 8;
1424 byte_idx < inventory.len() && inventory[byte_idx] & (1 << bit_idx) != 0
1425}
1426
1427fn set_component(inventory: &mut Vec<u8>, index: u32) {
1429 let byte_idx = (index / 8) as usize;
1430 let bit_idx = index % 8;
1431 if byte_idx >= inventory.len() {
1432 inventory.resize(byte_idx + 1, 0);
1433 }
1434 inventory[byte_idx] |= 1 << bit_idx;
1435}
1436
1437pub fn parse_inventory(hex: &str) -> Result<Vec<u8>, HandlerError> {
1441 if hex.is_empty() {
1442 return Ok(Vec::new());
1443 }
1444 if !hex.len().is_multiple_of(2) {
1445 return Err(HandlerError::Protocol(
1446 webui_protocol::ProtocolError::Validation(format!(
1447 "inventory hex has odd length: {}",
1448 hex.len()
1449 )),
1450 ));
1451 }
1452 let mut bytes = Vec::with_capacity(hex.len() / 2);
1453 let mut chars = hex.bytes();
1454 while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
1455 let h = route_matcher::hex_val(hi).ok_or_else(|| {
1456 HandlerError::Protocol(webui_protocol::ProtocolError::Validation(format!(
1457 "invalid hex digit in inventory: {:#04x}",
1458 hi
1459 )))
1460 })?;
1461 let l = route_matcher::hex_val(lo).ok_or_else(|| {
1462 HandlerError::Protocol(webui_protocol::ProtocolError::Validation(format!(
1463 "invalid hex digit in inventory: {:#04x}",
1464 lo
1465 )))
1466 })?;
1467 bytes.push((h << 4) | l);
1468 }
1469 Ok(bytes)
1470}
1471
1472pub fn encode_inventory(inv: &[u8]) -> String {
1474 const HEX: &[u8; 16] = b"0123456789abcdef";
1475 let mut encoded = String::with_capacity(inv.len() * 2);
1476 for byte in inv {
1477 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
1478 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
1479 }
1480 encoded
1481}
1482
1483pub(crate) fn encode_component_inventory(
1485 component_names: &HashSet<String>,
1486 index: &HashMap<String, u32>,
1487) -> String {
1488 let mut inventory = Vec::new();
1489 for name in component_names {
1490 if let Some(&idx) = index.get(name.as_str()) {
1491 set_component(&mut inventory, idx);
1492 }
1493 }
1494 encode_inventory(&inventory)
1495}
1496
1497pub fn get_needed_components(
1502 protocol: &WebUIProtocol,
1503 entry_id: &str,
1504 inventory_hex: &str,
1505 component_index: &HashMap<String, u32>,
1506) -> Result<(Vec<String>, String), HandlerError> {
1507 let route_index = CompiledRouteIndex::new(protocol);
1508 let component_names =
1509 collect_inventoryable_components(protocol, entry_id, None, true, &route_index);
1510 filter_needed_components(&component_names, inventory_hex, component_index)
1511}
1512
1513#[cfg(test)]
1518pub(crate) fn get_needed_components_for_request(
1519 protocol: &WebUIProtocol,
1520 entry_id: &str,
1521 request_path: &str,
1522 inventory_hex: &str,
1523 indexes: (&HashMap<String, u32>, &CompiledRouteIndex),
1524) -> Result<(Vec<String>, String), HandlerError> {
1525 let (component_index, route_index) = indexes;
1526 let component_names = collect_inventoryable_components(
1527 protocol,
1528 entry_id,
1529 Some(request_path),
1530 false,
1531 route_index,
1532 );
1533 filter_needed_components(&component_names, inventory_hex, component_index)
1534}
1535
1536fn serialize_partial_response(
1537 response: Value,
1538 state_json: &str,
1539 state_selection: &StateSelection<'_>,
1540) -> Result<String, HandlerError> {
1541 let state = select_raw_state(state_json, state_selection)?;
1542 let response = PartialResponseWithState::new(response, state)?;
1543 serde_json::to_string(&response).map_err(|error| partial_serialize_error(&error.to_string()))
1544}
1545
1546fn validate_json(json: &str) -> Result<(), HandlerError> {
1547 validate_json_inner(json).map_err(|error| invalid_state_json(&error.to_string()))
1548}
1549
1550fn validate_json_inner(json: &str) -> serde_json::Result<()> {
1551 let mut deserializer = serde_json::Deserializer::from_str(json);
1552 ValidJson
1553 .deserialize(&mut deserializer)
1554 .and_then(|_| deserializer.end())
1555}
1556
1557struct ValidJson;
1558
1559impl<'de> DeserializeSeed<'de> for ValidJson {
1560 type Value = ();
1561
1562 fn deserialize<D>(self, deserializer: D) -> std::result::Result<(), D::Error>
1563 where
1564 D: serde::Deserializer<'de>,
1565 {
1566 deserializer.deserialize_any(ValidJsonVisitor)
1567 }
1568}
1569
1570struct ValidJsonVisitor;
1571
1572impl<'de> Visitor<'de> for ValidJsonVisitor {
1573 type Value = ();
1574
1575 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1576 formatter.write_str("valid JSON")
1577 }
1578
1579 fn visit_bool<E>(self, _value: bool) -> std::result::Result<(), E> {
1580 Ok(())
1581 }
1582
1583 fn visit_i64<E>(self, _value: i64) -> std::result::Result<(), E> {
1584 Ok(())
1585 }
1586
1587 fn visit_u64<E>(self, _value: u64) -> std::result::Result<(), E> {
1588 Ok(())
1589 }
1590
1591 fn visit_f64<E>(self, value: f64) -> std::result::Result<(), E>
1592 where
1593 E: serde::de::Error,
1594 {
1595 if value.is_finite() {
1596 Ok(())
1597 } else {
1598 Err(E::custom("number out of range"))
1599 }
1600 }
1601
1602 fn visit_str<E>(self, _value: &str) -> std::result::Result<(), E> {
1603 Ok(())
1604 }
1605
1606 fn visit_borrowed_str<E>(self, _value: &'de str) -> std::result::Result<(), E> {
1607 Ok(())
1608 }
1609
1610 fn visit_string<E>(self, _value: String) -> std::result::Result<(), E> {
1611 Ok(())
1612 }
1613
1614 fn visit_unit<E>(self) -> std::result::Result<(), E> {
1615 Ok(())
1616 }
1617
1618 fn visit_none<E>(self) -> std::result::Result<(), E> {
1619 Ok(())
1620 }
1621
1622 fn visit_some<D>(self, deserializer: D) -> std::result::Result<(), D::Error>
1623 where
1624 D: serde::Deserializer<'de>,
1625 {
1626 ValidJson.deserialize(deserializer)
1627 }
1628
1629 fn visit_seq<A>(self, mut sequence: A) -> std::result::Result<(), A::Error>
1630 where
1631 A: SeqAccess<'de>,
1632 {
1633 while sequence.next_element_seed(ValidJson)?.is_some() {}
1634 Ok(())
1635 }
1636
1637 fn visit_map<A>(self, mut map: A) -> std::result::Result<(), A::Error>
1638 where
1639 A: MapAccess<'de>,
1640 {
1641 while map.next_key::<IgnoredAny>()?.is_some() {
1642 map.next_value_seed(ValidJson)?;
1643 }
1644 Ok(())
1645 }
1646}
1647
1648struct PartialResponseWithState<State> {
1649 response: Map<String, Value>,
1650 state: State,
1651}
1652
1653impl<State> PartialResponseWithState<State> {
1654 fn new(response: Value, state: State) -> Result<Self, HandlerError> {
1655 let Value::Object(response) = response else {
1656 return Err(partial_response_not_object());
1657 };
1658 Ok(Self { response, state })
1659 }
1660}
1661
1662impl<State: Serialize> Serialize for PartialResponseWithState<State> {
1663 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1664 where
1665 S: serde::Serializer,
1666 {
1667 let mut map = serializer.serialize_map(Some(self.response.len().saturating_add(1)))?;
1668 for (key, value) in &self.response {
1669 map.serialize_entry(key, value)?;
1670 }
1671 map.serialize_entry("state", &self.state)?;
1672 map.end()
1673 }
1674}
1675
1676struct ProjectedRawState<'de> {
1677 entries: Vec<(Cow<'de, str>, &'de RawValue)>,
1678}
1679
1680enum SelectedRawState<'de> {
1681 Full(&'de RawValue),
1682 Keys(ProjectedRawState<'de>),
1683}
1684impl Serialize for SelectedRawState<'_> {
1685 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1686 where
1687 S: serde::Serializer,
1688 {
1689 match self {
1690 Self::Full(state) => state.serialize(serializer),
1691 Self::Keys(state) => state.serialize(serializer),
1692 }
1693 }
1694}
1695
1696impl Serialize for ProjectedRawState<'_> {
1697 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1698 where
1699 S: serde::Serializer,
1700 {
1701 let mut map = serializer.serialize_map(Some(self.entries.len()))?;
1702 for (key, value) in &self.entries {
1703 map.serialize_entry(key.as_ref(), value)?;
1704 }
1705 map.end()
1706 }
1707}
1708
1709fn project_raw_state<'de>(
1710 state_json: &'de str,
1711 state_keys: &[&str],
1712) -> Result<ProjectedRawState<'de>, HandlerError> {
1713 let is_object = state_json
1714 .as_bytes()
1715 .iter()
1716 .find(|byte| !byte.is_ascii_whitespace())
1717 .is_some_and(|byte| *byte == b'{');
1718 if !is_object {
1719 validate_json(state_json)?;
1720 return Ok(ProjectedRawState {
1721 entries: Vec::new(),
1722 });
1723 }
1724
1725 let mut deserializer = serde_json::Deserializer::from_str(state_json);
1726 let mut state = ProjectedRawStateSeed { state_keys }
1727 .deserialize(&mut deserializer)
1728 .and_then(|state| {
1729 deserializer.end()?;
1730 Ok(state)
1731 })
1732 .map_err(|error| invalid_state_json(&error.to_string()))?;
1733 state.entries.sort_unstable_by(|left, right| {
1734 left.0.as_ref().as_bytes().cmp(right.0.as_ref().as_bytes())
1735 });
1736 Ok(state)
1737}
1738
1739fn select_raw_state<'de>(
1740 state_json: &'de str,
1741 selection: &StateSelection<'_>,
1742) -> Result<SelectedRawState<'de>, HandlerError> {
1743 let state_keys = match selection {
1744 StateSelection::Full => {
1745 if contains_reserved_state_key(state_json) {
1750 return strip_reserved_raw_state(state_json).map(SelectedRawState::Keys);
1751 }
1752 return serde_json::from_str::<&RawValue>(state_json)
1753 .map(SelectedRawState::Full)
1754 .map_err(|error| invalid_state_json(&error.to_string()));
1755 }
1756 StateSelection::Keys(keys) => keys.as_slice(),
1757 StateSelection::KeyIds(_) | StateSelection::FullExceptKeyIds(_) => {
1761 return Err(unexpected_key_id_selection());
1762 }
1763 };
1764 project_raw_state(state_json, state_keys).map(SelectedRawState::Keys)
1765}
1766
1767fn contains_reserved_state_key(state_json: &str) -> bool {
1773 let bytes = state_json.as_bytes();
1774 let mut search_start = 0;
1775 while let Some(relative_index) = memchr2(b'$', b'\\', &bytes[search_start..]) {
1776 let candidate = search_start + relative_index;
1777 if candidate > 0
1778 && bytes[candidate - 1] == b'"'
1779 && matches_reserved_state_key(bytes, candidate)
1780 {
1781 return contains_top_level_reserved_state_key(bytes);
1782 }
1783 search_start = candidate + 1;
1784 }
1785 false
1786}
1787
1788fn contains_top_level_reserved_state_key(bytes: &[u8]) -> bool {
1789 let Some(mut cursor) = bytes.iter().position(|byte| !byte.is_ascii_whitespace()) else {
1790 return false;
1791 };
1792 if bytes[cursor] != b'{' {
1793 return false;
1794 }
1795
1796 cursor += 1;
1797 let mut depth = 1;
1798 while cursor < bytes.len() {
1799 match bytes[cursor] {
1800 b'"' => {
1801 if depth == 1 && matches_reserved_state_key(bytes, cursor + 1) {
1802 return true;
1803 }
1804 cursor = skip_json_string(bytes, cursor + 1);
1805 }
1806 b'{' | b'[' => {
1807 depth += 1;
1808 cursor += 1;
1809 }
1810 b'}' | b']' => {
1811 depth -= 1;
1812 if depth == 0 {
1813 return false;
1814 }
1815 cursor += 1;
1816 }
1817 _ => cursor += 1,
1818 }
1819 }
1820 false
1821}
1822
1823fn skip_json_string(bytes: &[u8], mut cursor: usize) -> usize {
1824 while let Some(relative_index) = memchr2(b'"', b'\\', &bytes[cursor..]) {
1825 cursor += relative_index;
1826 if bytes[cursor] == b'"' {
1827 return cursor + 1;
1828 }
1829 cursor = (cursor + 2).min(bytes.len());
1830 }
1831 bytes.len()
1832}
1833
1834fn matches_reserved_state_key(bytes: &[u8], mut cursor: usize) -> bool {
1835 for expected in crate::STATE_INJECT_KEY.bytes() {
1836 if bytes.get(cursor).is_some_and(|byte| *byte == expected) {
1837 cursor += 1;
1838 } else if matches_unicode_escape(bytes, cursor, expected) {
1839 cursor += 6;
1840 } else {
1841 return false;
1842 }
1843 }
1844
1845 if bytes.get(cursor) != Some(&b'"') {
1846 return false;
1847 }
1848 cursor += 1;
1849 while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1850 cursor += 1;
1851 }
1852 bytes.get(cursor) == Some(&b':')
1853}
1854
1855fn matches_unicode_escape(bytes: &[u8], cursor: usize, expected: u8) -> bool {
1856 let Some(escape) = bytes.get(cursor..cursor.saturating_add(6)) else {
1857 return false;
1858 };
1859 escape[..4] == *b"\\u00"
1860 && decode_hex_nibble(escape[4]).is_some_and(|high| {
1861 decode_hex_nibble(escape[5]).is_some_and(|low| (high << 4) | low == expected)
1862 })
1863}
1864
1865fn decode_hex_nibble(byte: u8) -> Option<u8> {
1866 match byte {
1867 b'0'..=b'9' => Some(byte - b'0'),
1868 b'a'..=b'f' => Some(byte - b'a' + 10),
1869 b'A'..=b'F' => Some(byte - b'A' + 10),
1870 _ => None,
1871 }
1872}
1873
1874fn strip_reserved_raw_state(state_json: &str) -> Result<ProjectedRawState<'_>, HandlerError> {
1876 let mut deserializer = serde_json::Deserializer::from_str(state_json);
1877 let projected = ReservedFilteredRawStateSeed
1878 .deserialize(&mut deserializer)
1879 .map_err(|error| invalid_state_json(&error.to_string()))?;
1880 deserializer
1881 .end()
1882 .map_err(|error| invalid_state_json(&error.to_string()))?;
1883 Ok(projected)
1884}
1885
1886struct ReservedFilteredRawStateSeed;
1887
1888impl<'de> DeserializeSeed<'de> for ReservedFilteredRawStateSeed {
1889 type Value = ProjectedRawState<'de>;
1890
1891 fn deserialize<D>(self, deserializer: D) -> std::result::Result<Self::Value, D::Error>
1892 where
1893 D: serde::Deserializer<'de>,
1894 {
1895 deserializer.deserialize_map(ReservedFilteredRawStateVisitor)
1896 }
1897}
1898
1899struct ReservedFilteredRawStateVisitor;
1900
1901impl<'de> Visitor<'de> for ReservedFilteredRawStateVisitor {
1902 type Value = ProjectedRawState<'de>;
1903
1904 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1905 formatter.write_str("a JSON object")
1906 }
1907
1908 fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
1909 where
1910 A: MapAccess<'de>,
1911 {
1912 let mut entries: Vec<(Cow<'de, str>, &'de RawValue)> = Vec::new();
1913 while let Some(key) = map.next_key_seed(BorrowedString)? {
1914 if key.as_ref() == crate::STATE_INJECT_KEY {
1915 map.next_value_seed(ValidJson)?;
1916 continue;
1917 }
1918 let value: &'de RawValue = map.next_value()?;
1919 validate_json_inner(value.get()).map_err(serde::de::Error::custom)?;
1920 entries.push((key, value));
1921 }
1922 Ok(ProjectedRawState { entries })
1923 }
1924}
1925
1926struct ProjectedRawStateSeed<'a> {
1927 state_keys: &'a [&'a str],
1928}
1929
1930impl<'de> DeserializeSeed<'de> for ProjectedRawStateSeed<'_> {
1931 type Value = ProjectedRawState<'de>;
1932
1933 fn deserialize<D>(self, deserializer: D) -> std::result::Result<Self::Value, D::Error>
1934 where
1935 D: serde::Deserializer<'de>,
1936 {
1937 deserializer.deserialize_map(ProjectedRawStateVisitor {
1938 state_keys: self.state_keys,
1939 })
1940 }
1941}
1942
1943struct ProjectedRawStateVisitor<'a> {
1944 state_keys: &'a [&'a str],
1945}
1946
1947impl<'de> Visitor<'de> for ProjectedRawStateVisitor<'_> {
1948 type Value = ProjectedRawState<'de>;
1949
1950 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1951 formatter.write_str("a JSON object")
1952 }
1953
1954 fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
1955 where
1956 A: MapAccess<'de>,
1957 {
1958 let mut entries: Vec<(Cow<'de, str>, &'de RawValue)> =
1959 Vec::with_capacity(self.state_keys.len());
1960 while let Some(key) = map.next_key_seed(BorrowedString)? {
1961 if key.as_ref() == crate::STATE_INJECT_KEY
1962 || self.state_keys.binary_search(&key.as_ref()).is_err()
1963 {
1964 map.next_value_seed(ValidJson)?;
1965 continue;
1966 }
1967
1968 let value: &'de RawValue = map.next_value()?;
1969 validate_json_inner(value.get()).map_err(serde::de::Error::custom)?;
1970 if let Some(entry) = entries
1971 .iter_mut()
1972 .find(|(existing, _)| existing.as_ref() == key.as_ref())
1973 {
1974 *entry = (key, value);
1975 } else {
1976 entries.push((key, value));
1977 }
1978 }
1979 Ok(ProjectedRawState { entries })
1980 }
1981}
1982
1983struct BorrowedString;
1984
1985impl<'de> DeserializeSeed<'de> for BorrowedString {
1986 type Value = Cow<'de, str>;
1987
1988 fn deserialize<D>(self, deserializer: D) -> std::result::Result<Self::Value, D::Error>
1989 where
1990 D: serde::Deserializer<'de>,
1991 {
1992 deserializer.deserialize_str(BorrowedStringVisitor)
1993 }
1994}
1995
1996struct BorrowedStringVisitor;
1997
1998impl<'de> Visitor<'de> for BorrowedStringVisitor {
1999 type Value = Cow<'de, str>;
2000
2001 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2002 formatter.write_str("a string")
2003 }
2004
2005 fn visit_borrowed_str<E>(self, value: &'de str) -> std::result::Result<Self::Value, E> {
2006 Ok(Cow::Borrowed(value))
2007 }
2008
2009 fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E> {
2010 Ok(Cow::Owned(value.to_owned()))
2011 }
2012
2013 fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E> {
2014 Ok(Cow::Owned(value))
2015 }
2016}
2017
2018#[cold]
2019#[inline(never)]
2020fn invalid_state_json(message: &str) -> HandlerError {
2021 HandlerError::InvalidState(message.to_string())
2022}
2023
2024#[cold]
2025#[inline(never)]
2026fn unexpected_key_id_selection() -> HandlerError {
2027 HandlerError::Invariant(
2028 "streaming hydration key-ID projection reached partial navigation".to_string(),
2029 )
2030}
2031
2032#[cold]
2033#[inline(never)]
2034fn partial_serialize_error(message: &str) -> HandlerError {
2035 HandlerError::Rendering(format!("failed to serialize partial response: {message}"))
2036}
2037
2038#[cold]
2039#[inline(never)]
2040fn partial_response_not_object() -> HandlerError {
2041 HandlerError::Invariant("partial response must serialize as a JSON object".to_string())
2042}
2043
2044pub(crate) fn collect_reachable_component_order_for_request(
2046 protocol: &WebUIProtocol,
2047 entry_id: &str,
2048 request_path: &str,
2049 route_index: &CompiledRouteIndex,
2050) -> Vec<String> {
2051 collect_inventoryable_components(protocol, entry_id, Some(request_path), false, route_index)
2052}
2053
2054pub fn filter_needed_components(
2064 component_names: &[String],
2065 inventory_hex: &str,
2066 index: &HashMap<String, u32>,
2067) -> Result<(Vec<String>, String), HandlerError> {
2068 let filtered = filter_components_with_inventory(component_names, inventory_hex, index)?;
2069 Ok((filtered.needed, filtered.updated_inventory))
2070}
2071
2072struct FilteredComponents {
2073 needed: Vec<String>,
2074 updated_inventory: String,
2075 client_inventory: Vec<u8>,
2076}
2077
2078fn filter_components_with_inventory(
2079 component_names: &[String],
2080 inventory_hex: &str,
2081 index: &HashMap<String, u32>,
2082) -> Result<FilteredComponents, HandlerError> {
2083 let client_inv = parse_inventory(inventory_hex)?;
2084 let mut updated_inv = client_inv.clone();
2085
2086 let mut needed = Vec::with_capacity(component_names.len());
2087 for name in component_names {
2088 if let Some(&idx) = index.get(name.as_str()) {
2089 if !has_component(&client_inv, idx) {
2090 needed.push(name.clone());
2091 }
2092 set_component(&mut updated_inv, idx);
2093 } else {
2094 needed.push(name.clone());
2098 }
2099 }
2100
2101 Ok(FilteredComponents {
2102 needed,
2103 updated_inventory: encode_inventory(&updated_inv),
2104 client_inventory: client_inv,
2105 })
2106}
2107
2108fn has_template_payload(component: &webui_protocol::ComponentData) -> bool {
2109 !component.template_json.is_empty() || !component.template.is_empty()
2110}
2111
2112#[derive(Debug)]
2113struct RouteBaseArena {
2121 bases: Vec<String>,
2122}
2123
2124impl RouteBaseArena {
2125 const ROOT: u32 = 0;
2127
2128 fn new() -> Self {
2129 Self {
2130 bases: vec!["/".to_string()],
2131 }
2132 }
2133
2134 fn find(&self, base: &str) -> Option<u32> {
2140 #[allow(clippy::cast_possible_truncation)]
2142 self.bases
2143 .iter()
2144 .position(|known| known == base)
2145 .map(|position| position as u32)
2146 }
2147
2148 #[allow(clippy::cast_possible_truncation)]
2149 fn push(&mut self, base: String) -> u32 {
2150 let id = self.bases.len() as u32;
2151 self.bases.push(base);
2152 id
2153 }
2154
2155 fn intern(&mut self, base: &str) -> u32 {
2160 match self.find(base) {
2161 Some(id) => id,
2162 None => self.push(base.to_string()),
2163 }
2164 }
2165
2166 fn intern_owned(&mut self, base: String) -> u32 {
2172 match self.find(&base) {
2173 Some(id) => id,
2174 None => self.push(base),
2175 }
2176 }
2177
2178 fn get(&self, id: u32) -> &str {
2179 self.bases.get(id as usize).map_or("/", String::as_str)
2180 }
2181}
2182
2183#[derive(Clone, Copy)]
2189struct QueuedFragment<'protocol> {
2190 id: &'protocol str,
2191 inventoryable: bool,
2192 route_base: u32,
2194}
2195
2196type VisitedKey<'protocol> = (&'protocol str, Option<u32>);
2201
2202#[inline]
2203fn mark_fragment_visited<'protocol>(
2204 visited: &mut HashSet<VisitedKey<'protocol>>,
2205 queued: &QueuedFragment<'protocol>,
2206 route_sensitive: bool,
2207) -> bool {
2208 visited.insert((queued.id, route_sensitive.then_some(queued.route_base)))
2209}
2210
2211struct ChildWalkCtx<'a> {
2214 request_path: &'a str,
2215 protocol: &'a WebUIProtocol,
2216 route_index: &'a CompiledRouteIndex,
2217}
2218
2219fn collect_inventoryable_components<'protocol>(
2230 protocol: &'protocol WebUIProtocol,
2231 entry_id: &'protocol str,
2232 request_path: Option<&str>,
2233 root_inventoryable: bool,
2234 route_index: &CompiledRouteIndex,
2235) -> Vec<String> {
2236 let mut arena = RouteBaseArena::new();
2237 let stack = vec![QueuedFragment {
2238 id: entry_id,
2239 inventoryable: root_inventoryable,
2240 route_base: RouteBaseArena::ROOT,
2241 }];
2242 collect_inventoryable_components_from_stack(
2243 protocol,
2244 request_path,
2245 route_index,
2246 stack,
2247 &mut arena,
2248 )
2249}
2250
2251pub(crate) fn collect_reachable_components_from_roots<'protocol>(
2259 protocol: &'protocol WebUIProtocol,
2260 roots: &[(u32, Option<Box<str>>)],
2261 reachability: &'protocol ComponentReachabilityIndex,
2262 request_path: &str,
2263 route_index: &CompiledRouteIndex,
2264) -> Vec<String> {
2265 let mut arena = RouteBaseArena::new();
2266 let mut stack = Vec::with_capacity(roots.len());
2267 for (root, route_base) in roots.iter().rev() {
2268 let Some(name) = reachability.name(*root) else {
2269 continue;
2270 };
2271 let route_base = match route_base.as_deref() {
2272 Some(base) => arena.intern(base),
2273 None => RouteBaseArena::ROOT,
2274 };
2275 stack.push(QueuedFragment {
2276 id: name,
2277 inventoryable: true,
2278 route_base,
2279 });
2280 }
2281 collect_inventoryable_components_from_stack(
2282 protocol,
2283 Some(request_path),
2284 route_index,
2285 stack,
2286 &mut arena,
2287 )
2288}
2289
2290fn collect_inventoryable_components_from_stack<'protocol>(
2291 protocol: &'protocol WebUIProtocol,
2292 request_path: Option<&str>,
2293 route_index: &CompiledRouteIndex,
2294 mut stack: Vec<QueuedFragment<'protocol>>,
2295 arena: &mut RouteBaseArena,
2296) -> Vec<String> {
2297 let mut visited_fragments = HashSet::new();
2298 let route_sensitive = request_path.is_some();
2299 let mut seen_components = HashSet::new();
2304 let mut component_ids: Vec<String> = Vec::new();
2305 while let Some(queued) = stack.pop() {
2306 if queued.id.is_empty() {
2307 continue;
2308 }
2309
2310 if queued.inventoryable && seen_components.insert(queued.id) {
2311 component_ids.push(queued.id.to_string());
2312 }
2313
2314 if !mark_fragment_visited(&mut visited_fragments, &queued, route_sensitive) {
2315 continue;
2316 }
2317
2318 let Some(frag_list) = protocol.fragments.get(queued.id) else {
2319 continue;
2320 };
2321
2322 let matched_route = request_path.and_then(|path| {
2323 route_renderer::find_best_route_match(
2324 &frag_list.fragments,
2325 path,
2326 arena.get(queued.route_base),
2327 route_index,
2328 )
2329 });
2330
2331 for frag in frag_list.fragments.iter().rev() {
2334 match frag.fragment.as_ref() {
2335 Some(Fragment::Component(component)) => {
2336 stack.push(QueuedFragment {
2337 id: &component.fragment_id,
2338 inventoryable: true,
2339 route_base: queued.route_base,
2340 });
2341 }
2342 Some(Fragment::ForLoop(for_loop)) => {
2343 stack.push(QueuedFragment {
2344 id: &for_loop.fragment_id,
2345 inventoryable: false,
2346 route_base: queued.route_base,
2347 });
2348 }
2349 Some(Fragment::IfCond(if_cond)) => {
2350 stack.push(QueuedFragment {
2351 id: &if_cond.fragment_id,
2352 inventoryable: false,
2353 route_base: queued.route_base,
2354 });
2355 }
2356
2357 Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
2358 stack.push(QueuedFragment {
2359 id: &attr.template,
2360 inventoryable: false,
2361 route_base: queued.route_base,
2362 });
2363 }
2364 Some(Fragment::Route(route_frag)) => {
2365 let is_selected = matched_route
2366 .as_ref()
2367 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
2368 if is_selected && !route_frag.content_fragment_id.is_empty() {
2369 stack.push(QueuedFragment {
2370 id: &route_frag.content_fragment_id,
2371 inventoryable: false,
2372 route_base: queued.route_base,
2373 });
2374 }
2375 if is_selected && !route_frag.fragment_id.is_empty() {
2376 let child_route_base = match (&matched_route, request_path) {
2378 (Some((_, rm)), Some(path)) => arena.intern_owned(
2379 route_matcher::compute_route_base(path, rm.consumed_segments),
2380 ),
2381 _ => queued.route_base,
2382 };
2383
2384 stack.push(QueuedFragment {
2385 id: &route_frag.fragment_id,
2386 inventoryable: protocol
2387 .components
2388 .get(&route_frag.fragment_id)
2389 .is_some_and(has_template_payload),
2390 route_base: child_route_base,
2391 });
2392
2393 collect_route_boundary_components(
2401 std::slice::from_ref(route_frag),
2402 child_route_base,
2403 protocol,
2404 &mut stack,
2405 );
2406
2407 if !route_frag.children.is_empty() {
2411 if let Some(path) = request_path {
2412 walk_route_children(
2413 &route_frag.children,
2414 child_route_base,
2415 &mut stack,
2416 arena,
2417 &ChildWalkCtx {
2418 request_path: path,
2419 protocol,
2420 route_index,
2421 },
2422 );
2423 }
2424 }
2425 }
2426 }
2427 _ => {}
2428 }
2429 }
2430 }
2431
2432 component_ids
2433}
2434
2435fn select_best_child_route(
2440 children: &[WebUIFragmentRoute],
2441 request_path: &str,
2442 route_base: &str,
2443 route_index: &CompiledRouteIndex,
2444) -> Option<(usize, route_matcher::RouteMatch)> {
2445 let request_segments = route_matcher::split_request_path(request_path);
2446 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
2447 for (idx, child) in children.iter().enumerate() {
2448 if let Some(m) = route_matcher::match_route_indexed_with_segments(
2449 route_index,
2450 &child.path,
2451 route_base,
2452 &request_segments,
2453 child.exact,
2454 ) {
2455 let is_better = best
2456 .as_ref()
2457 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
2458 if is_better {
2459 best = Some((idx, m));
2460 }
2461 }
2462 }
2463 best
2464}
2465
2466fn walk_route_children<'protocol>(
2469 children: &'protocol [WebUIFragmentRoute],
2470 route_base: u32,
2471 stack: &mut Vec<QueuedFragment<'protocol>>,
2472 arena: &mut RouteBaseArena,
2473 ctx: &ChildWalkCtx<'_>,
2474) {
2475 let mut current = children;
2476 let mut base = route_base;
2477
2478 loop {
2479 let matched =
2482 select_best_child_route(current, ctx.request_path, arena.get(base), ctx.route_index);
2483 let Some((idx, rm)) = matched else {
2484 break;
2485 };
2486 let Some(matched) = current.get(idx) else {
2487 break;
2488 };
2489 if matched.fragment_id.is_empty() {
2490 break;
2491 }
2492
2493 let child_base = arena.intern_owned(route_matcher::compute_route_base(
2494 ctx.request_path,
2495 rm.consumed_segments,
2496 ));
2497
2498 stack.push(QueuedFragment {
2499 id: &matched.fragment_id,
2500 inventoryable: ctx
2501 .protocol
2502 .components
2503 .get(&matched.fragment_id)
2504 .is_some_and(has_template_payload),
2505 route_base: child_base,
2506 });
2507
2508 if !matched.pending_component.is_empty() {
2509 stack.push(QueuedFragment {
2510 id: &matched.pending_component,
2511 inventoryable: ctx
2512 .protocol
2513 .components
2514 .get(&matched.pending_component)
2515 .is_some_and(has_template_payload),
2516 route_base: child_base,
2517 });
2518 }
2519 if !matched.error_component.is_empty() {
2520 stack.push(QueuedFragment {
2521 id: &matched.error_component,
2522 inventoryable: ctx
2523 .protocol
2524 .components
2525 .get(&matched.error_component)
2526 .is_some_and(has_template_payload),
2527 route_base: child_base,
2528 });
2529 }
2530
2531 if matched.children.is_empty() {
2532 break;
2533 }
2534 current = &matched.children;
2535 base = child_base;
2536 }
2537}
2538
2539fn collect_route_boundary_components<'protocol>(
2548 routes: &'protocol [WebUIFragmentRoute],
2549 route_base: u32,
2550 protocol: &WebUIProtocol,
2551 stack: &mut Vec<QueuedFragment<'protocol>>,
2552) {
2553 let mut remaining: Vec<&'protocol WebUIFragmentRoute> = routes.iter().collect();
2554 while let Some(route) = remaining.pop() {
2555 for component in [&route.pending_component, &route.error_component] {
2556 if component.is_empty() {
2557 continue;
2558 }
2559 stack.push(QueuedFragment {
2560 id: component,
2561 inventoryable: protocol
2562 .components
2563 .get(component)
2564 .is_some_and(has_template_payload),
2565 route_base,
2566 });
2567 }
2568 remaining.extend(route.children.iter());
2569 }
2570}
2571
2572pub fn collect_nested_route_params(
2577 protocol: &Protocol,
2578 entry_id: &str,
2579 request_path: &str,
2580) -> HashMap<String, String> {
2581 let route_index = protocol.route_index();
2582 let protocol = protocol.protocol();
2583 let mut all_params = HashMap::new();
2584 let mut visited_fragments = HashSet::new();
2585 let mut arena = RouteBaseArena::new();
2586 let mut stack = vec![QueuedFragment {
2587 id: entry_id,
2588 inventoryable: false,
2589 route_base: RouteBaseArena::ROOT,
2590 }];
2591
2592 while let Some(queued) = stack.pop() {
2593 if queued.id.is_empty() || !mark_fragment_visited(&mut visited_fragments, &queued, true) {
2594 continue;
2595 }
2596
2597 let Some(frag_list) = protocol.fragments.get(queued.id) else {
2598 continue;
2599 };
2600
2601 let matched_route = route_renderer::find_best_route_match(
2602 &frag_list.fragments,
2603 request_path,
2604 arena.get(queued.route_base),
2605 route_index,
2606 );
2607
2608 for frag in &frag_list.fragments {
2609 match frag.fragment.as_ref() {
2610 Some(Fragment::Component(component)) => {
2611 stack.push(QueuedFragment {
2612 id: &component.fragment_id,
2613 inventoryable: false,
2614 route_base: queued.route_base,
2615 });
2616 }
2617 Some(Fragment::ForLoop(for_loop)) => {
2618 stack.push(QueuedFragment {
2619 id: &for_loop.fragment_id,
2620 inventoryable: false,
2621 route_base: queued.route_base,
2622 });
2623 }
2624 Some(Fragment::IfCond(if_cond)) => {
2625 stack.push(QueuedFragment {
2626 id: &if_cond.fragment_id,
2627 inventoryable: false,
2628 route_base: queued.route_base,
2629 });
2630 }
2631
2632 Some(Fragment::Route(route_frag)) => {
2633 let is_selected = matched_route
2634 .as_ref()
2635 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
2636 if is_selected && !route_frag.fragment_id.is_empty() {
2637 if let Some((_, ref rm)) = matched_route {
2638 for (name, value) in &rm.params {
2642 all_params.insert(name.clone(), value.clone());
2643 }
2644
2645 let child_route_base =
2646 arena.intern_owned(route_matcher::compute_route_base(
2647 request_path,
2648 rm.consumed_segments,
2649 ));
2650
2651 stack.push(QueuedFragment {
2652 id: &route_frag.fragment_id,
2653 inventoryable: false,
2654 route_base: child_route_base,
2655 });
2656
2657 collect_params_from_children(
2659 &route_frag.children,
2660 request_path,
2661 arena.get(child_route_base),
2662 &mut all_params,
2663 route_index,
2664 );
2665 }
2666 }
2667 }
2668 _ => {}
2669 }
2670 }
2671 }
2672
2673 all_params
2674}
2675
2676fn collect_params_from_children(
2678 children: &[WebUIFragmentRoute],
2679 request_path: &str,
2680 route_base: &str,
2681 all_params: &mut HashMap<String, String>,
2682 route_index: &CompiledRouteIndex,
2683) {
2684 let mut current = children;
2685 let mut base = route_base.to_string();
2686
2687 while let Some((idx, rm)) = select_best_child_route(current, request_path, &base, route_index) {
2688 all_params.extend(rm.params);
2689 let matched = ¤t[idx];
2690 if matched.children.is_empty() {
2691 break;
2692 }
2693 let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
2694 current = &matched.children;
2695 base = child_base;
2696 }
2697}
2698
2699fn resolve_tag_templates(templates: &[String], params: &HashMap<String, String>) -> Vec<String> {
2707 let mut resolved = Vec::with_capacity(templates.len());
2708 for template in templates {
2709 if !template.contains('{') {
2710 resolved.push(template.clone());
2711 continue;
2712 }
2713 let mut result = String::with_capacity(template.len());
2714 let mut rest = template.as_str();
2715 while let Some(open) = rest.find('{') {
2716 result.push_str(&rest[..open]);
2717 rest = &rest[open + 1..];
2718 if let Some(close) = rest.find('}') {
2719 let name = &rest[..close];
2720 if let Some(value) = params.get(name) {
2721 result.push_str(value);
2722 } else {
2723 result.push('{');
2724 result.push_str(name);
2725 result.push('}');
2726 }
2727 rest = &rest[close + 1..];
2728 } else {
2729 result.push('{');
2730 }
2731 }
2732 result.push_str(rest);
2733 resolved.push(result);
2734 }
2735 resolved
2736}
2737
2738fn collect_inventory_and_chain<'protocol>(
2742 protocol: &'protocol WebUIProtocol,
2743 entry_id: &'protocol str,
2744 request_path: &str,
2745 index: &mut RequestProtocolIndex<'_>,
2746) -> (Vec<String>, Vec<RouteChainEntry>) {
2747 let mut visited_fragments = HashSet::new();
2748 let mut seen_components = HashSet::new();
2753 let mut component_ids: Vec<String> = Vec::new();
2754 let mut chain = Vec::new();
2755 let mut arena = RouteBaseArena::new();
2756 let mut stack = vec![QueuedFragment {
2757 id: entry_id,
2758 inventoryable: false,
2759 route_base: RouteBaseArena::ROOT,
2760 }];
2761
2762 while let Some(queued) = stack.pop() {
2763 if queued.id.is_empty() {
2764 continue;
2765 }
2766
2767 if queued.inventoryable && seen_components.insert(queued.id) {
2768 component_ids.push(queued.id.to_string());
2769 }
2770
2771 if !mark_fragment_visited(&mut visited_fragments, &queued, true) {
2772 continue;
2773 }
2774
2775 let Some(frag_list) = protocol.fragments.get(queued.id) else {
2776 continue;
2777 };
2778
2779 let matched_route = route_renderer::find_best_route_match(
2780 &frag_list.fragments,
2781 request_path,
2782 arena.get(queued.route_base),
2783 index.route_index,
2784 );
2785
2786 for frag in &frag_list.fragments {
2787 match frag.fragment.as_ref() {
2788 Some(Fragment::Component(component)) => {
2789 stack.push(QueuedFragment {
2791 id: &component.fragment_id,
2792 inventoryable: true,
2793 route_base: queued.route_base,
2794 });
2795 }
2796 Some(Fragment::ForLoop(for_loop)) => {
2797 stack.push(QueuedFragment {
2799 id: &for_loop.fragment_id,
2800 inventoryable: false,
2801 route_base: queued.route_base,
2802 });
2803 }
2804 Some(Fragment::IfCond(if_cond)) => {
2805 stack.push(QueuedFragment {
2806 id: &if_cond.fragment_id,
2807 inventoryable: false,
2808 route_base: queued.route_base,
2809 });
2810 }
2811
2812 Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
2813 stack.push(QueuedFragment {
2814 id: &attr.template,
2815 inventoryable: false,
2816 route_base: queued.route_base,
2817 });
2818 }
2819 Some(Fragment::Route(route_frag)) => {
2820 let is_selected = matched_route
2821 .as_ref()
2822 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
2823 if is_selected && !route_frag.content_fragment_id.is_empty() {
2824 stack.push(QueuedFragment {
2825 id: &route_frag.content_fragment_id,
2826 inventoryable: false,
2827 route_base: queued.route_base,
2828 });
2829 }
2830 if is_selected && !route_frag.fragment_id.is_empty() {
2831 if let Some((_, ref rm)) = matched_route {
2832 chain.push(RouteChainEntry {
2834 component: route_frag.fragment_id.clone(),
2835 path: route_frag.path.clone(),
2836 params: rm.params.clone(),
2837 exact: route_frag.exact,
2838 allowed_query: route_frag.allowed_query.clone(),
2839 keep_alive: route_frag.keep_alive,
2840 cache_tags: route_frag.cache_tags.clone(),
2841 invalidates: route_frag.invalidates.clone(),
2842 pending_component: route_frag.pending_component.clone(),
2843 error_component: route_frag.error_component.clone(),
2844 });
2845
2846 let child_route_base =
2847 arena.intern_owned(route_matcher::compute_route_base(
2848 request_path,
2849 rm.consumed_segments,
2850 ));
2851
2852 let is_inventoryable = protocol
2854 .components
2855 .get(&route_frag.fragment_id)
2856 .is_some_and(has_template_payload);
2857 stack.push(QueuedFragment {
2858 id: &route_frag.fragment_id,
2859 inventoryable: is_inventoryable,
2860 route_base: child_route_base,
2861 });
2862
2863 collect_route_boundary_components(
2864 std::slice::from_ref(route_frag),
2865 child_route_base,
2866 protocol,
2867 &mut stack,
2868 );
2869
2870 if !route_frag.children.is_empty() {
2872 walk_children_for_inventory_and_chain(
2873 &route_frag.children,
2874 child_route_base,
2875 ChainWalkSinks {
2876 stack: &mut stack,
2877 chain: &mut chain,
2878 arena: &mut arena,
2879 },
2880 &ChildWalkCtx {
2881 request_path,
2882 protocol,
2883 route_index: index.route_index,
2884 },
2885 );
2886 }
2887 }
2888 }
2889 }
2890 _ => {}
2891 }
2892 }
2893 }
2894
2895 (component_ids, chain)
2896}
2897
2898struct ChainWalkSinks<'walk, 'protocol> {
2902 stack: &'walk mut Vec<QueuedFragment<'protocol>>,
2903 chain: &'walk mut Vec<RouteChainEntry>,
2904 arena: &'walk mut RouteBaseArena,
2905}
2906
2907fn walk_children_for_inventory_and_chain<'protocol>(
2909 children: &'protocol [WebUIFragmentRoute],
2910 route_base: u32,
2911 sinks: ChainWalkSinks<'_, 'protocol>,
2912 ctx: &ChildWalkCtx<'_>,
2913) {
2914 let ChainWalkSinks {
2915 stack,
2916 chain,
2917 arena,
2918 } = sinks;
2919 let mut current = children;
2920 let mut base = route_base;
2921
2922 loop {
2923 let matched =
2926 select_best_child_route(current, ctx.request_path, arena.get(base), ctx.route_index);
2927 let Some((idx, rm)) = matched else {
2928 break;
2929 };
2930 let Some(matched) = current.get(idx) else {
2931 break;
2932 };
2933 if matched.fragment_id.is_empty() {
2934 break;
2935 }
2936
2937 let child_base = arena.intern_owned(route_matcher::compute_route_base(
2938 ctx.request_path,
2939 rm.consumed_segments,
2940 ));
2941
2942 chain.push(RouteChainEntry {
2943 component: matched.fragment_id.clone(),
2944 path: matched.path.clone(),
2945 params: rm.params,
2946 exact: matched.exact,
2947 allowed_query: matched.allowed_query.clone(),
2948 keep_alive: matched.keep_alive,
2949 cache_tags: matched.cache_tags.clone(),
2950 invalidates: matched.invalidates.clone(),
2951 pending_component: matched.pending_component.clone(),
2952 error_component: matched.error_component.clone(),
2953 });
2954
2955 stack.push(QueuedFragment {
2956 id: &matched.fragment_id,
2957 inventoryable: ctx
2958 .protocol
2959 .components
2960 .get(&matched.fragment_id)
2961 .is_some_and(has_template_payload),
2962 route_base: child_base,
2963 });
2964
2965 if matched.children.is_empty() {
2966 break;
2967 }
2968 current = &matched.children;
2969 base = child_base;
2970 }
2971}
2972
2973#[cfg(test)]
2988fn render_partial_metadata(
2989 protocol: &WebUIProtocol,
2990 entry_id: &str,
2991 request_path: &str,
2992 inventory_hex: &str,
2993 index: &mut ProtocolIndex,
2994) -> Result<Value, HandlerError> {
2995 let mut request_index = index.request_index();
2996 render_partial_indexed(
2997 protocol,
2998 entry_id,
2999 request_path,
3000 inventory_hex,
3001 &mut request_index,
3002 )
3003}
3004
3005#[cfg(test)]
3012fn render_partial(
3013 protocol: &WebUIProtocol,
3014 state: Value,
3015 entry_id: &str,
3016 request_path: &str,
3017 inventory_hex: &str,
3018) -> Result<Value, HandlerError> {
3019 let mut index = ProtocolIndex::new(protocol);
3020 let mut request_index = index.request_index();
3021 let (mut response, state_selection, _) = render_partial_indexed_with_state(
3022 protocol,
3023 entry_id,
3024 request_path,
3025 inventory_hex,
3026 &mut request_index,
3027 )?;
3028 let response = response
3029 .as_object_mut()
3030 .ok_or_else(partial_response_not_object)?;
3031 response.insert("state".into(), select_owned_state(state, &state_selection));
3032 Ok(Value::Object(std::mem::take(response)))
3033}
3034
3035#[cfg(test)]
3036fn render_partial_indexed(
3037 protocol: &WebUIProtocol,
3038 entry_id: &str,
3039 request_path: &str,
3040 inventory_hex: &str,
3041 index: &mut RequestProtocolIndex<'_>,
3042) -> Result<Value, HandlerError> {
3043 render_partial_indexed_with_state(protocol, entry_id, request_path, inventory_hex, index)
3044 .map(|(response, _, _)| response)
3045}
3046
3047fn render_partial_indexed_with_state<'a>(
3048 protocol: &'a WebUIProtocol,
3049 entry_id: &str,
3050 request_path: &str,
3051 inventory_hex: &str,
3052 index: &mut RequestProtocolIndex<'_>,
3053) -> Result<(Value, StateSelection<'a>, bool), HandlerError> {
3054 let (component_ids, mut chain) =
3056 collect_inventory_and_chain(protocol, entry_id, request_path, index);
3057 let matched = route_chain_matches(&chain, request_path);
3058 let state_selection =
3059 crate::collect_navigation_state(protocol, component_ids.iter().map(String::as_str));
3060
3061 let filtered =
3062 filter_components_with_inventory(&component_ids, inventory_hex, index.component_index)?;
3063
3064 let mut accumulated_params: HashMap<String, String> = HashMap::new();
3066 let mut all_resolved_tags: Vec<String> = Vec::new();
3067
3068 for entry in &mut chain {
3069 for (k, v) in &entry.params {
3070 accumulated_params.insert(k.clone(), v.clone());
3071 }
3072 entry.cache_tags = resolve_tag_templates(&entry.cache_tags, &accumulated_params);
3073 entry.invalidates = resolve_tag_templates(&entry.invalidates, &accumulated_params);
3074 all_resolved_tags.extend(entry.cache_tags.iter().cloned());
3075 }
3076
3077 let tag_refs: Vec<&str> = filtered.needed.iter().map(String::as_str).collect();
3078 let assets = collect_component_assets(protocol, &tag_refs, &filtered.client_inventory, index)?;
3079
3080 let chain_array = Value::Array(chain.iter().map(RouteChainEntry::to_json).collect());
3081
3082 let mut result = serde_json::Map::with_capacity(7);
3083 result.insert("componentStyles".into(), assets.component_styles);
3084 result.insert("templates".into(), Value::Object(assets.templates));
3085 result.insert("templateFunctions".into(), Value::Object(assets.functions));
3086 result.insert(
3087 "inventory".into(),
3088 Value::String(filtered.updated_inventory),
3089 );
3090 result.insert("path".into(), Value::String(request_path.to_string()));
3091 result.insert("chain".into(), chain_array);
3092 if !all_resolved_tags.is_empty() {
3093 let mut seen = HashSet::new();
3094 let deduped: Vec<Value> = all_resolved_tags
3095 .into_iter()
3096 .filter(|t| seen.insert(t.clone()))
3097 .map(Value::String)
3098 .collect();
3099 result.insert("cacheTags".into(), Value::Array(deduped));
3100 }
3101 Ok((Value::Object(result), state_selection, matched))
3102}
3103
3104#[derive(serde::Serialize)]
3109#[serde(transparent)]
3110#[must_use]
3111pub struct PartialNavigation {
3112 response: PartialResponseWithState<Value>,
3113 #[serde(skip)]
3114 matched: bool,
3115}
3116
3117impl PartialNavigation {
3118 #[must_use]
3120 pub fn is_match(&self) -> bool {
3121 self.matched
3122 }
3123}
3124
3125fn route_chain_matches(chain: &[RouteChainEntry], request_path: &str) -> bool {
3126 if request_path
3127 .split_once('?')
3128 .map_or(request_path, |(path, _)| path)
3129 == "/"
3130 {
3131 return !chain.is_empty();
3132 }
3133 match chain {
3134 [] => false,
3135 [only] => only.path != "/",
3136 _ => true,
3137 }
3138}
3139
3140fn select_owned_state(state: Value, selection: &StateSelection<'_>) -> Value {
3141 let state_keys = match selection {
3142 StateSelection::Full => {
3143 let mut state = state;
3144 if let Value::Object(state) = &mut state {
3145 state.remove(crate::STATE_INJECT_KEY);
3146 }
3147 return state;
3148 }
3149 StateSelection::Keys(keys) => keys.as_slice(),
3150 StateSelection::KeyIds(_) | StateSelection::FullExceptKeyIds(_) => {
3152 return Value::Object(Map::new());
3153 }
3154 };
3155 let Value::Object(mut source) = state else {
3156 return Value::Object(Map::new());
3157 };
3158 let mut projected = Map::with_capacity(state_keys.len().min(source.len()));
3159 for &key in state_keys {
3160 if key == crate::STATE_INJECT_KEY {
3161 continue;
3162 }
3163 if let Some(value) = source.remove(key) {
3164 projected.insert(key.to_owned(), value);
3165 }
3166 }
3167 Value::Object(projected)
3168}
3169
3170#[cfg(test)]
3181fn render_action_response(
3182 protocol: &WebUIProtocol,
3183 state: Value,
3184 entry_id: &str,
3185 request_path: &str,
3186 index: &mut ProtocolIndex,
3187) -> Value {
3188 let mut request_index = index.request_index();
3189 render_action_response_indexed(protocol, state, entry_id, request_path, &mut request_index)
3190}
3191
3192#[cfg(test)]
3193fn render_action_response_indexed(
3194 protocol: &WebUIProtocol,
3195 state: Value,
3196 entry_id: &str,
3197 request_path: &str,
3198 index: &mut RequestProtocolIndex<'_>,
3199) -> Value {
3200 let chain = collect_route_chain(protocol, entry_id, request_path, index.route_index);
3201
3202 let mut accumulated_params: HashMap<String, String> = HashMap::new();
3204 let mut all_invalidates: Vec<String> = Vec::new();
3205
3206 for entry in &chain {
3207 for (k, v) in &entry.params {
3208 accumulated_params.insert(k.clone(), v.clone());
3209 }
3210 let resolved = resolve_tag_templates(&entry.invalidates, &accumulated_params);
3211 all_invalidates.extend(resolved);
3212 }
3213
3214 let mut seen = HashSet::new();
3216 let deduped: Vec<Value> = all_invalidates
3217 .into_iter()
3218 .filter(|t| seen.insert(t.clone()))
3219 .map(Value::String)
3220 .collect();
3221
3222 let mut result = serde_json::Map::with_capacity(3);
3223 result.insert("state".into(), state);
3224 result.insert("invalidateTags".into(), Value::Array(deduped));
3225 result.insert("path".into(), Value::String(request_path.to_string()));
3226 Value::Object(result)
3227}
3228
3229#[cfg(test)]
3238fn render_component_templates(
3239 protocol: &WebUIProtocol,
3240 component_tags: &[&str],
3241 inventory_hex: &str,
3242 index: &mut ProtocolIndex,
3243) -> Result<Value, HandlerError> {
3244 let mut request_index = index.request_index();
3245 render_component_templates_indexed(protocol, component_tags, inventory_hex, &mut request_index)
3246}
3247
3248fn render_component_templates_indexed(
3249 protocol: &WebUIProtocol,
3250 component_tags: &[&str],
3251 inventory_hex: &str,
3252 index: &mut RequestProtocolIndex<'_>,
3253) -> Result<Value, HandlerError> {
3254 let mut seen: HashSet<&str> = HashSet::new();
3256 let requested: Vec<String> = component_tags
3257 .iter()
3258 .filter(|s| seen.insert(**s))
3259 .map(|s| (*s).to_string())
3260 .collect();
3261 let filtered =
3262 filter_components_with_inventory(&requested, inventory_hex, index.component_index)?;
3263
3264 let tag_refs: Vec<&str> = filtered.needed.iter().map(String::as_str).collect();
3265 let assets = collect_component_assets(protocol, &tag_refs, &filtered.client_inventory, index)?;
3266
3267 let mut result = serde_json::Map::with_capacity(4);
3268 result.insert("componentStyles".into(), assets.component_styles);
3269 result.insert("templates".into(), Value::Object(assets.templates));
3270 result.insert("templateFunctions".into(), Value::Object(assets.functions));
3271 result.insert(
3272 "inventory".into(),
3273 Value::String(filtered.updated_inventory),
3274 );
3275 Ok(Value::Object(result))
3276}
3277
3278fn collect_component_assets(
3280 protocol: &WebUIProtocol,
3281 tags: &[&str],
3282 client_inventory: &[u8],
3283 index: &mut RequestProtocolIndex<'_>,
3284) -> Result<ComponentAssets, HandlerError> {
3285 let component_styles = collect_component_styles_for_inventory(
3286 protocol,
3287 tags.iter().copied(),
3288 client_inventory,
3289 index.component_index,
3290 )?;
3291 let mut tmpl_map = serde_json::Map::new();
3292 let mut function_map = serde_json::Map::new();
3293
3294 let mut sorted_tags: Vec<&str> = tags.to_vec();
3296 sorted_tags.sort_unstable();
3297
3298 for tag in sorted_tags {
3299 let Some(component) = protocol.components.get(tag) else {
3300 continue;
3301 };
3302 if !has_template_payload(component) {
3303 continue;
3304 }
3305 if !component.template_json.is_empty() {
3306 let template_value = cached_template_metadata(
3307 &mut index.template_metadata_cache,
3308 tag,
3309 &component.template_json,
3310 )?;
3311 tmpl_map.insert(tag.to_string(), template_value);
3312 if !component.template_functions.is_empty() {
3313 function_map.insert(
3314 tag.to_string(),
3315 Value::String(component.template_functions.clone()),
3316 );
3317 }
3318 } else {
3319 tmpl_map.insert(tag.to_string(), Value::String(component.template.clone()));
3320 }
3321 }
3322
3323 Ok(ComponentAssets {
3324 component_styles,
3325 templates: tmpl_map,
3326 functions: function_map,
3327 })
3328}
3329
3330fn cached_template_metadata(
3331 cache: &mut TemplateMetadataCache<'_>,
3332 tag: &str,
3333 template_json: &str,
3334) -> Result<Value, HandlerError> {
3335 match cache {
3336 #[cfg(test)]
3337 TemplateMetadataCache::Exclusive(cache) => {
3338 cached_template_metadata_exclusive(cache, tag, template_json)
3339 }
3340 TemplateMetadataCache::Shared(cache) => {
3341 if let Some(value) = cache
3342 .read()
3343 .map_err(|_| protocol_metadata_cache_poisoned())?
3344 .get(tag)
3345 .cloned()
3346 {
3347 return Ok(value);
3348 }
3349
3350 let value = parse_template_metadata(tag, template_json)?;
3351 let mut cache = cache
3352 .write()
3353 .map_err(|_| protocol_metadata_cache_poisoned())?;
3354 Ok(cache.entry(tag.to_string()).or_insert(value).clone())
3355 }
3356 }
3357}
3358
3359#[cfg(test)]
3360fn cached_template_metadata_exclusive(
3361 cache: &mut HashMap<String, Value>,
3362 tag: &str,
3363 template_json: &str,
3364) -> Result<Value, HandlerError> {
3365 if let Some(value) = cache.get(tag) {
3366 return Ok(value.clone());
3367 }
3368
3369 let value = parse_template_metadata(tag, template_json)?;
3370 cache.insert(tag.to_string(), value.clone());
3371 Ok(value)
3372}
3373
3374fn parse_template_metadata(tag: &str, template_json: &str) -> Result<Value, HandlerError> {
3375 serde_json::from_str(template_json).map_err(|error| {
3376 HandlerError::Rendering(format!(
3377 "failed to parse template metadata for {tag}: {error}"
3378 ))
3379 })
3380}
3381
3382#[cold]
3383#[inline(never)]
3384fn protocol_metadata_cache_poisoned() -> HandlerError {
3385 HandlerError::Invariant(
3386 "protocol metadata cache is unavailable after a previous panic".to_string(),
3387 )
3388}
3389
3390#[derive(Debug, Clone)]
3394pub struct RouteChainEntry {
3395 pub component: String,
3397 pub path: String,
3399 pub params: HashMap<String, String>,
3401 pub exact: bool,
3403 pub allowed_query: String,
3405 pub keep_alive: bool,
3407 pub cache_tags: Vec<String>,
3409 pub invalidates: Vec<String>,
3411 pub pending_component: String,
3413 pub error_component: String,
3415}
3416
3417pub(crate) struct RouteChainPlan {
3418 pub(crate) entries: Vec<RouteChainEntry>,
3419 pub(crate) document_style_targets: Vec<bool>,
3420}
3421
3422impl RouteChainEntry {
3423 #[must_use]
3425 pub fn to_json(&self) -> Value {
3426 let mut obj = serde_json::Map::with_capacity(9);
3427 obj.insert("component".into(), Value::String(self.component.clone()));
3428 obj.insert("path".into(), Value::String(self.path.clone()));
3429 if !self.params.is_empty() {
3430 let params_obj: serde_json::Map<String, Value> = self
3431 .params
3432 .iter()
3433 .map(|(k, v)| (k.clone(), Value::String(v.clone())))
3434 .collect();
3435 obj.insert("params".into(), Value::Object(params_obj));
3436 }
3437 if self.exact {
3438 obj.insert("exact".into(), Value::Bool(true));
3439 }
3440 if !self.allowed_query.is_empty() {
3441 obj.insert(
3442 "allowedQuery".into(),
3443 Value::String(self.allowed_query.clone()),
3444 );
3445 }
3446 if self.keep_alive {
3447 obj.insert("keepAlive".into(), Value::Bool(true));
3448 }
3449 if !self.pending_component.is_empty() {
3450 obj.insert(
3451 "pendingComponent".into(),
3452 Value::String(self.pending_component.clone()),
3453 );
3454 }
3455 if !self.error_component.is_empty() {
3456 obj.insert(
3457 "errorComponent".into(),
3458 Value::String(self.error_component.clone()),
3459 );
3460 }
3461 if !self.invalidates.is_empty() {
3462 obj.insert(
3463 "invalidates".into(),
3464 Value::Array(
3465 self.invalidates
3466 .iter()
3467 .map(|s| Value::String(s.clone()))
3468 .collect(),
3469 ),
3470 );
3471 }
3472 Value::Object(obj)
3473 }
3474}
3475
3476#[cfg(test)]
3481pub(crate) fn collect_route_chain(
3482 protocol: &WebUIProtocol,
3483 entry_id: &str,
3484 request_path: &str,
3485 route_index: &CompiledRouteIndex,
3486) -> Vec<RouteChainEntry> {
3487 collect_route_chain_plan(protocol, entry_id, request_path, route_index).entries
3488}
3489
3490struct RouteChainWork {
3491 id: String,
3492 route_base: String,
3493 targets_document: bool,
3494}
3495
3496pub(crate) fn collect_route_chain_plan(
3497 protocol: &WebUIProtocol,
3498 entry_id: &str,
3499 request_path: &str,
3500 route_index: &CompiledRouteIndex,
3501) -> RouteChainPlan {
3502 let mut chain = Vec::new();
3503 let mut document_style_targets = Vec::new();
3504 let mut visited_fragments = HashSet::new();
3505 let mut stack = vec![RouteChainWork {
3506 id: entry_id.to_string(),
3507 route_base: "/".to_string(),
3508 targets_document: !protocol.component_uses_shadow_dom(entry_id),
3509 }];
3510
3511 while let Some(queued) = stack.pop() {
3512 if queued.id.is_empty()
3513 || !visited_fragments.insert((queued.id.clone(), queued.route_base.clone()))
3514 {
3515 continue;
3516 }
3517
3518 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
3519 continue;
3520 };
3521
3522 let matched_route = route_renderer::find_best_route_match(
3523 &frag_list.fragments,
3524 request_path,
3525 &queued.route_base,
3526 route_index,
3527 );
3528
3529 for frag in &frag_list.fragments {
3530 match frag.fragment.as_ref() {
3531 Some(Fragment::Component(component)) => {
3532 stack.push(RouteChainWork {
3533 id: component.fragment_id.clone(),
3534 route_base: queued.route_base.clone(),
3535 targets_document: queued.targets_document
3536 && !protocol.component_uses_shadow_dom(&component.fragment_id),
3537 });
3538 }
3539 Some(Fragment::ForLoop(for_loop)) => {
3540 stack.push(RouteChainWork {
3541 id: for_loop.fragment_id.clone(),
3542 route_base: queued.route_base.clone(),
3543 targets_document: queued.targets_document,
3544 });
3545 }
3546 Some(Fragment::IfCond(if_cond)) => {
3547 stack.push(RouteChainWork {
3548 id: if_cond.fragment_id.clone(),
3549 route_base: queued.route_base.clone(),
3550 targets_document: queued.targets_document,
3551 });
3552 }
3553
3554 Some(Fragment::Route(route_frag)) => {
3555 let is_selected = matched_route
3556 .as_ref()
3557 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
3558 if is_selected && !route_frag.fragment_id.is_empty() {
3559 if let Some((_, ref rm)) = matched_route {
3560 let targets_document = queued.targets_document
3561 && !protocol.component_uses_shadow_dom(&route_frag.fragment_id);
3562 chain.push(RouteChainEntry {
3563 component: route_frag.fragment_id.clone(),
3564 path: route_frag.path.clone(),
3565 params: rm.params.clone(),
3566 exact: route_frag.exact,
3567 allowed_query: route_frag.allowed_query.clone(),
3568 keep_alive: route_frag.keep_alive,
3569 cache_tags: route_frag.cache_tags.clone(),
3570 invalidates: route_frag.invalidates.clone(),
3571 pending_component: route_frag.pending_component.clone(),
3572 error_component: route_frag.error_component.clone(),
3573 });
3574 document_style_targets.push(targets_document);
3575
3576 let child_route_base = route_matcher::compute_route_base(
3577 request_path,
3578 rm.consumed_segments,
3579 );
3580
3581 stack.push(RouteChainWork {
3582 id: route_frag.fragment_id.clone(),
3583 route_base: child_route_base.clone(),
3584 targets_document,
3585 });
3586
3587 let mut output = RouteChainOutput {
3589 entries: &mut chain,
3590 document_style_targets: &mut document_style_targets,
3591 };
3592 collect_chain_from_children(
3593 &route_frag.children,
3594 &child_route_base,
3595 targets_document,
3596 &mut output,
3597 &mut ChildWalkCtx {
3598 request_path,
3599 protocol,
3600 route_index,
3601 },
3602 );
3603 }
3604 }
3605 }
3606 _ => {}
3607 }
3608 }
3609 }
3610
3611 RouteChainPlan {
3612 entries: chain,
3613 document_style_targets,
3614 }
3615}
3616
3617struct RouteChainOutput<'a> {
3618 entries: &'a mut Vec<RouteChainEntry>,
3619 document_style_targets: &'a mut Vec<bool>,
3620}
3621
3622fn collect_chain_from_children(
3624 children: &[WebUIFragmentRoute],
3625 route_base: &str,
3626 targets_document: bool,
3627 output: &mut RouteChainOutput<'_>,
3628 ctx: &mut ChildWalkCtx<'_>,
3629) {
3630 let mut pending: Vec<(&[WebUIFragmentRoute], String, bool)> =
3631 vec![(children, route_base.to_string(), targets_document)];
3632
3633 while let Some((current, base, current_targets_document)) = pending.pop() {
3634 if let Some((idx, rm)) =
3635 select_best_child_route(current, ctx.request_path, &base, ctx.route_index)
3636 {
3637 let matched = ¤t[idx];
3638 let matched_targets_document = current_targets_document
3639 && !ctx.protocol.component_uses_shadow_dom(&matched.fragment_id);
3640 output.entries.push(RouteChainEntry {
3641 component: matched.fragment_id.clone(),
3642 path: matched.path.clone(),
3643 params: rm.params,
3644 exact: matched.exact,
3645 allowed_query: matched.allowed_query.clone(),
3646 keep_alive: matched.keep_alive,
3647 cache_tags: matched.cache_tags.clone(),
3648 invalidates: matched.invalidates.clone(),
3649 pending_component: matched.pending_component.clone(),
3650 error_component: matched.error_component.clone(),
3651 });
3652 output.document_style_targets.push(matched_targets_document);
3653 if !matched.children.is_empty() {
3654 let child_base =
3655 route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
3656 pending.push((&matched.children, child_base, matched_targets_document));
3657 }
3658 }
3659 }
3660}
3661
3662#[cfg(test)]
3663mod tests {
3664 use super::*;
3665 use std::collections::HashMap;
3666 use std::sync::{Arc, Barrier};
3667 use std::thread;
3668 use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
3669
3670 #[test]
3671 fn protocol_caches_inline_style_resources_requiring_escape() {
3672 let mut protocol = WebUIProtocol::new(HashMap::new());
3673 protocol.set_css_strategy(CssStrategy::Style);
3674 protocol.components.insert(
3675 "safe-card".to_string(),
3676 webui_protocol::ComponentData {
3677 css: ".safe{color:green}".to_string(),
3678 ..Default::default()
3679 },
3680 );
3681 protocol.components.insert(
3682 "unsafe-card".to_string(),
3683 webui_protocol::ComponentData {
3684 css: ".unsafe{content:'</StYlE>'}".to_string(),
3685 ..Default::default()
3686 },
3687 );
3688 protocol.style_chunks.push(webui_protocol::StyleChunk {
3689 name: "_chunk-safe-card-2".to_string(),
3690 css: ".safe{color:green}.other{color:blue}".to_string(),
3691 ..Default::default()
3692 });
3693 protocol.style_chunks.push(webui_protocol::StyleChunk {
3694 name: "_chunk-unsafe-card-2".to_string(),
3695 css: ".unsafe{content:'</style>'}.other{color:blue}".to_string(),
3696 ..Default::default()
3697 });
3698
3699 let prepared = Protocol::new(protocol);
3700 let resources = prepared.style_resources_requiring_escape();
3701
3702 assert_eq!(resources.len(), 2);
3703 assert!(resources.contains("unsafe-card"));
3704 assert!(resources.contains("_chunk-unsafe-card-2"));
3705 }
3706
3707 #[test]
3708 fn protocol_decodes_once_and_exposes_tokens() {
3709 let mut fragments = HashMap::new();
3710 fragments.insert(
3711 "index.html".to_string(),
3712 FragmentList {
3713 fragments: vec![WebUIFragment::raw("<p>Prepared</p>")],
3714 contains_boundary: false,
3715 },
3716 );
3717 let protocol = WebUIProtocol::with_tokens(fragments, vec!["colorBrand".to_string()]);
3718 let bytes = protocol.to_protobuf().unwrap();
3719 let prepared = Protocol::from_protobuf(&bytes).unwrap();
3720
3721 assert!(prepared.protocol().fragments.contains_key("index.html"));
3722 assert_eq!(prepared.tokens(), ["colorBrand"]);
3723 }
3724
3725 #[test]
3726 fn continuation_state_plans_memoize_per_entry_without_locking() {
3727 let mut fragments = HashMap::new();
3730 fragments.insert(
3731 "index.html".to_string(),
3732 FragmentList {
3733 fragments: vec![WebUIFragment::raw("<p>plan</p>")],
3734 contains_boundary: false,
3735 },
3736 );
3737 let prepared = Protocol::new(WebUIProtocol::new(fragments));
3738
3739 let first = prepared.continuation_state_plan("index.html").unwrap();
3740 let second = prepared.continuation_state_plan("index.html").unwrap();
3741 assert!(
3742 std::ptr::eq(first, second),
3743 "a memoized plan must be borrowed, not rebuilt or cloned"
3744 );
3745 let cell = std::mem::size_of::<OnceLock<PreparedContinuationStatePlan>>();
3748 assert!(
3749 cell <= 40,
3750 "continuation plan memo cell grew to {cell} bytes"
3751 );
3752
3753 let barrier = Arc::new(Barrier::new(4));
3754 let address = std::ptr::from_ref(first).addr();
3755 thread::scope(|scope| {
3756 for _ in 0..4 {
3757 let barrier = Arc::clone(&barrier);
3758 let prepared = &prepared;
3759 scope.spawn(move || {
3760 barrier.wait();
3761 let plan = prepared.continuation_state_plan("index.html").unwrap();
3762 assert_eq!(
3763 std::ptr::from_ref(plan).addr(),
3764 address,
3765 "concurrent responses must share one initialization"
3766 );
3767 assert!(plan.resolve().is_ok());
3768 });
3769 }
3770 });
3771 }
3772
3773 #[test]
3774 fn continuation_state_plans_replay_captured_failures() {
3775 let mut fragments = HashMap::new();
3778 fragments.insert(
3779 "index.html".to_string(),
3780 FragmentList {
3781 fragments: vec![WebUIFragment::component("missing-card")],
3782 contains_boundary: false,
3783 },
3784 );
3785 let prepared = Protocol::new(WebUIProtocol::new(fragments));
3786
3787 for _ in 0..2 {
3788 match prepared
3789 .continuation_state_plan("index.html")
3790 .and_then(|plan| plan.resolve())
3791 .err()
3792 {
3793 Some(HandlerError::MissingFragment(id)) => assert_eq!(id, "missing-card"),
3794 other => panic!("expected a replayable missing-record failure, got {other:?}"),
3795 }
3796 }
3797 match prepared.continuation_state_plan("absent.html").err() {
3798 Some(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent.html"),
3799 other => panic!("expected a missing-entry diagnostic, got {other:?}"),
3800 }
3801 }
3802
3803 #[test]
3804 fn component_reachability_interns_hydration_projections() {
3805 let mut fragments = HashMap::new();
3809 fragments.insert("keyed-card".to_string(), FragmentList::default());
3810 fragments.insert("all-card".to_string(), FragmentList::default());
3811 fragments.insert("bare-card".to_string(), FragmentList::default());
3812 let mut protocol = WebUIProtocol::new(fragments);
3813 protocol.components.insert(
3814 "keyed-card".to_string(),
3815 webui_protocol::ComponentData {
3816 hydration_mode: StateProjectionMode::Keys as i32,
3817 hydration_keys: vec!["zebra".to_string(), "alpha".to_string()],
3818 ..Default::default()
3819 },
3820 );
3821 protocol.components.insert(
3822 "all-card".to_string(),
3823 webui_protocol::ComponentData {
3824 hydration_mode: StateProjectionMode::All as i32,
3825 ..Default::default()
3826 },
3827 );
3828 let prepared = Protocol::new(protocol);
3829 let index = prepared.component_reachability();
3830 let component = prepared.component_index();
3831
3832 let mut ids = Vec::new();
3833 assert!(
3834 !index.extend_hydration_keys(component["keyed-card"], &mut ids),
3835 "a keyed surface projects keys"
3836 );
3837 ids.sort_unstable();
3838 let keys: Vec<&str> = ids
3839 .iter()
3840 .filter_map(|id| index.hydration_key(*id))
3841 .collect();
3842 assert_eq!(
3843 keys,
3844 ["alpha", "zebra"],
3845 "sorted IDs must be lexicographically sorted keys"
3846 );
3847
3848 let mut ids = Vec::new();
3849 assert!(
3850 index.extend_hydration_keys(component["all-card"], &mut ids),
3851 "an ALL surface forces full state"
3852 );
3853 assert!(
3854 index.extend_hydration_keys(component["bare-card"], &mut ids),
3855 "a component without compiled metadata forces full state"
3856 );
3857 }
3858
3859 #[test]
3860 fn protocol_retains_only_serialized_component_asset_styles() {
3861 let mut protocol = WebUIProtocol::default();
3862 protocol.component_asset_style_preloads =
3863 vec![webui_protocol::ComponentAssetStylePreload {
3864 root: "lazy-panel".to_string(),
3865 style_hrefs: vec!["/lazy-panel.css".to_string()],
3866 }];
3867
3868 let prepared = Protocol::new(protocol);
3869
3870 assert!(prepared
3871 .protocol()
3872 .component_asset_style_preloads
3873 .is_empty());
3874 assert_eq!(
3875 prepared.component_asset_style_manifest().unwrap(),
3876 r#"{"lazy-panel":["/lazy-panel.css"]}"#
3877 );
3878 assert!(prepared.component_asset_style_links().is_empty());
3879 }
3880
3881 #[test]
3882 fn protocol_precomputes_deduplicated_light_component_asset_styles() {
3883 let mut protocol = WebUIProtocol::default();
3884 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
3885 for root in ["lazy-panel", "secondary-panel"] {
3886 protocol.components.insert(
3887 root.to_string(),
3888 webui_protocol::ComponentData {
3889 uses_shadow_dom: false,
3890 ..Default::default()
3891 },
3892 );
3893 }
3894 protocol.component_asset_style_preloads = vec![
3895 webui_protocol::ComponentAssetStylePreload {
3896 root: "lazy-panel".to_string(),
3897 style_hrefs: vec![
3898 "/lazy-panel.css".to_string(),
3899 "/shared.css?theme=a&mode=\"dark\"".to_string(),
3900 ],
3901 },
3902 webui_protocol::ComponentAssetStylePreload {
3903 root: "secondary-panel".to_string(),
3904 style_hrefs: vec![
3905 "/secondary-panel.css".to_string(),
3906 "/shared.css?theme=a&mode=\"dark\"".to_string(),
3907 ],
3908 },
3909 ];
3910
3911 let prepared = Protocol::new(protocol);
3912
3913 assert!(prepared
3914 .protocol()
3915 .component_asset_style_preloads
3916 .is_empty());
3917 assert!(prepared
3918 .component_asset_style_manifest()
3919 .unwrap()
3920 .is_empty());
3921 assert_eq!(
3922 prepared.component_asset_style_links(),
3923 concat!(
3924 r#"<link rel="stylesheet" href="/lazy-panel.css">"#,
3925 r#"<link rel="stylesheet" href="/shared.css?theme=a&mode="dark"">"#,
3926 r#"<link rel="stylesheet" href="/secondary-panel.css">"#,
3927 )
3928 );
3929 }
3930
3931 #[test]
3932 fn protocol_partial_matches_direct_index_path() {
3933 let mut fragments = HashMap::new();
3934 fragments.insert(
3935 "index.html".to_string(),
3936 FragmentList {
3937 fragments: vec![WebUIFragment::route("/", "home-page")],
3938 contains_boundary: false,
3939 },
3940 );
3941 fragments.insert(
3942 "home-page".to_string(),
3943 FragmentList {
3944 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
3945 contains_boundary: false,
3946 },
3947 );
3948 let protocol = WebUIProtocol::new(fragments);
3949 let mut direct_index = ProtocolIndex::new(&protocol);
3950 let direct =
3951 render_partial_metadata(&protocol, "index.html", "/", "", &mut direct_index).unwrap();
3952 let prepared = Protocol::new(protocol);
3953 let cached = prepared
3954 .render_partial_metadata("index.html", "/", "")
3955 .unwrap();
3956
3957 assert_eq!(cached, direct);
3958 }
3959
3960 #[test]
3961 fn protocol_partial_supports_concurrent_metadata_reads() {
3962 let mut fragments = HashMap::new();
3963 fragments.insert(
3964 "index.html".to_string(),
3965 FragmentList {
3966 fragments: vec![WebUIFragment::route("/", "home-page")],
3967 contains_boundary: false,
3968 },
3969 );
3970 fragments.insert(
3971 "home-page".to_string(),
3972 FragmentList {
3973 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
3974 contains_boundary: false,
3975 },
3976 );
3977 let mut protocol = WebUIProtocol::new(fragments);
3978 protocol.components.insert(
3979 "home-page".to_string(),
3980 webui_protocol::ComponentData {
3981 template_json: r#"{"h":"<p>Home</p>"}"#.to_string(),
3982 ..Default::default()
3983 },
3984 );
3985
3986 let prepared = Arc::new(Protocol::new(protocol));
3987 let barrier = Arc::new(Barrier::new(4));
3988 let handles: Vec<_> = (0..4)
3989 .map(|_| {
3990 let prepared = Arc::clone(&prepared);
3991 let barrier = Arc::clone(&barrier);
3992 thread::spawn(move || {
3993 barrier.wait();
3994 for _ in 0..50 {
3995 let response = prepared
3996 .render_partial_metadata("index.html", "/", "")
3997 .unwrap();
3998 assert_eq!(response["templates"]["home-page"]["h"], "<p>Home</p>");
3999 }
4000 })
4001 })
4002 .collect();
4003
4004 for handle in handles {
4005 handle.join().unwrap();
4006 }
4007 }
4008
4009 fn prepared_partial_protocol(hydration_keys: &[&str]) -> Protocol {
4010 let mut fragments = HashMap::new();
4011 fragments.insert(
4012 "index.html".to_string(),
4013 FragmentList {
4014 fragments: vec![WebUIFragment::route("/", "home-page")],
4015 contains_boundary: false,
4016 },
4017 );
4018 fragments.insert(
4019 "home-page".to_string(),
4020 FragmentList {
4021 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
4022 contains_boundary: false,
4023 },
4024 );
4025 let mut protocol = WebUIProtocol::new(fragments);
4026 protocol.components.insert(
4027 "home-page".to_string(),
4028 webui_protocol::ComponentData {
4029 template_json: r#"{"h":"<p>Home</p>"}"#.to_string(),
4030 hydration_mode: webui_protocol::StateProjectionMode::Keys as i32,
4031 hydration_keys: hydration_keys
4032 .iter()
4033 .map(|key| (*key).to_string())
4034 .collect(),
4035 navigation_mode: Some(webui_protocol::StateProjectionMode::Keys as i32),
4036 navigation_keys: hydration_keys
4037 .iter()
4038 .map(|key| (*key).to_string())
4039 .collect(),
4040 ..Default::default()
4041 },
4042 );
4043 Protocol::new(protocol)
4044 }
4045
4046 fn prepared_full_state_partial_protocol() -> Protocol {
4047 let mut prepared = prepared_partial_protocol(&[]);
4048 let protocol = &mut prepared.protocol;
4049 if let Some(component) = protocol.components.get_mut("home-page") {
4050 component.navigation_mode = Some(webui_protocol::StateProjectionMode::All as i32);
4051 component.navigation_keys.clear();
4052 }
4053 prepared
4054 }
4055
4056 #[test]
4057 fn partial_state_serialization_preserves_validated_raw_json() {
4058 let prepared = prepared_partial_protocol(&["value"]);
4059 let output = prepared
4060 .render_partial_json(
4061 r#"{"serverOnly":"drop","value":1e2}"#,
4062 "index.html",
4063 "/",
4064 "",
4065 )
4066 .unwrap();
4067 assert!(output.contains(r#""state":{"value":1e2}"#));
4068 assert!(!output.contains("serverOnly"));
4069
4070 let parsed: Value = serde_json::from_str(&output).unwrap();
4071 assert_eq!(parsed["state"]["value"], 100.0);
4072 }
4073
4074 #[test]
4075 fn partial_state_projection_strips_reserved_inject_key() {
4076 let prepared = prepared_partial_protocol(&[crate::STATE_INJECT_KEY, "value"]);
4077 let output = prepared
4078 .render_partial_json(
4079 r#"{"$webui":{"bodyEnd":"<script>secret</script>"},"serverOnly":"drop","value":1}"#,
4080 "index.html",
4081 "/",
4082 "",
4083 )
4084 .unwrap();
4085 let parsed: Value = serde_json::from_str(&output).unwrap();
4086
4087 assert_eq!(parsed["state"], serde_json::json!({"value": 1}));
4088 assert!(!output.contains("<script>secret</script>"), "{output}");
4089 }
4090
4091 #[test]
4092 fn parsed_partial_state_moves_selected_values_into_response() {
4093 let prepared = prepared_partial_protocol(&[crate::STATE_INJECT_KEY, "value"]);
4094 let output = prepared
4095 .render_partial(
4096 serde_json::json!({
4097 "$webui": {"bodyEnd": "<script>secret</script>"},
4098 "serverOnly": ["large", "discarded", "value"],
4099 "value": {"nested": "kept"}
4100 }),
4101 "index.html",
4102 "/",
4103 "",
4104 )
4105 .unwrap();
4106 let parsed: Value = serde_json::from_str(&output).unwrap();
4107
4108 assert_eq!(
4109 parsed["state"],
4110 serde_json::json!({"value": {"nested": "kept"}})
4111 );
4112 assert!(!output.contains("serverOnly"));
4113 assert!(!output.contains("<script>secret</script>"), "{output}");
4114 }
4115
4116 #[test]
4117 fn uncertain_partial_surface_preserves_complete_raw_state() {
4118 let prepared = prepared_full_state_partial_protocol();
4119 let output = prepared
4120 .render_partial_json(
4121 r#"{"serverOnly":"keep","value":1e2}"#,
4122 "index.html",
4123 "/",
4124 "",
4125 )
4126 .unwrap();
4127 assert!(output.contains(r#""state":{"serverOnly":"keep","value":1e2}"#));
4128 }
4129
4130 #[test]
4131 fn uncertain_partial_surface_strips_escaped_reserved_inject_key() {
4132 let prepared = prepared_full_state_partial_protocol();
4133 for state in [
4134 r#"{"\u0024webui":{"bodyEnd":"<script>secret</script>"},"serverOnly":"keep","value":1}"#,
4135 r#"{"$\u0077\u0065\u0062\u0075\u0069":{"bodyEnd":"<script>secret</script>"},"serverOnly":"keep","value":1}"#,
4136 ] {
4137 let output = prepared
4138 .render_partial_json(state, "index.html", "/", "")
4139 .unwrap();
4140 let parsed: Value = serde_json::from_str(&output).unwrap();
4141
4142 assert_eq!(
4143 parsed["state"],
4144 serde_json::json!({"serverOnly": "keep", "value": 1})
4145 );
4146 assert!(!output.contains("<script>secret</script>"), "{output}");
4147 }
4148 }
4149
4150 #[test]
4151 fn parsed_full_partial_state_strips_reserved_inject_key() {
4152 let prepared = prepared_full_state_partial_protocol();
4153 let output = prepared
4154 .render_partial(
4155 serde_json::json!({
4156 "$webui": {"bodyEnd": "<script>secret</script>"},
4157 "serverOnly": "keep",
4158 "value": 1
4159 }),
4160 "index.html",
4161 "/",
4162 "",
4163 )
4164 .unwrap();
4165 let parsed: Value = serde_json::from_str(&output).unwrap();
4166
4167 assert_eq!(
4168 parsed["state"],
4169 serde_json::json!({"serverOnly": "keep", "value": 1})
4170 );
4171 assert!(!output.contains("<script>secret</script>"), "{output}");
4172 }
4173
4174 #[test]
4175 fn uncertain_partial_surface_preserves_nested_reserved_key_bytes() {
4176 let prepared = prepared_full_state_partial_protocol();
4177 let output = prepared
4178 .render_partial_json(
4179 r#"{"outer": { "$webui": {"bodyEnd": "application data"} }, "value": 1e2}"#,
4180 "index.html",
4181 "/",
4182 "",
4183 )
4184 .unwrap();
4185
4186 assert!(
4187 output.contains(
4188 r#""state":{"outer": { "$webui": {"bodyEnd": "application data"} }, "value": 1e2}"#
4189 ),
4190 "{output}"
4191 );
4192 }
4193
4194 #[test]
4195 fn partial_state_serialization_rejects_invalid_json() {
4196 let prepared = prepared_partial_protocol(&[]);
4197 let error = prepared
4198 .render_partial_json(r#"{"broken":"#, "index.html", "/", "")
4199 .expect_err("invalid state JSON must fail");
4200 assert!(
4201 matches!(error, HandlerError::InvalidState(_)),
4202 "host bindings classify caller state errors by variant, got {error:?}"
4203 );
4204 assert!(error.to_string().contains("invalid state JSON"));
4205 }
4206
4207 #[test]
4208 fn partial_state_serialization_emits_empty_state_without_client_components() {
4209 let prepared = Protocol::new(WebUIProtocol::default());
4210 let output = prepared
4211 .render_partial_json(r#"{"serverOnly":"drop"}"#, "index.html", "/", "")
4212 .unwrap();
4213 let parsed: Value = serde_json::from_str(&output).unwrap();
4214 assert_eq!(parsed["state"], serde_json::json!({}));
4215 }
4216
4217 #[test]
4218 fn partial_state_serialization_validates_selected_and_skipped_numbers() {
4219 let prepared = prepared_partial_protocol(&["value"]);
4220 for state in [r#"{"value":1e9999}"#, r#"{"serverOnly":1e9999,"value":1}"#] {
4221 let error = prepared
4222 .render_partial_json(state, "index.html", "/", "")
4223 .expect_err("out-of-range state numbers must fail");
4224 assert!(
4225 matches!(error, HandlerError::InvalidState(_)),
4226 "host bindings classify caller state errors by variant, got {error:?}"
4227 );
4228 assert!(error.to_string().contains("invalid state JSON"));
4229 }
4230 }
4231
4232 #[test]
4233 fn partial_state_serialization_uses_last_duplicate_and_decodes_keys() {
4234 let prepared = prepared_partial_protocol(&["value"]);
4235 let output = prepared
4236 .render_partial_json(r#"{"value":1,"va\u006cue":2}"#, "index.html", "/", "")
4237 .unwrap();
4238 let parsed: Value = serde_json::from_str(&output).unwrap();
4239 assert_eq!(parsed["state"], serde_json::json!({"value": 2}));
4240 }
4241
4242 #[test]
4243 fn parsed_partial_state_projects_without_cloning_server_only_values() {
4244 let prepared = prepared_partial_protocol(&["value"]);
4245 let response = render_partial(
4246 prepared.protocol(),
4247 serde_json::json!({"serverOnly": [1, 2, 3], "value": "kept"}),
4248 "index.html",
4249 "/",
4250 "",
4251 )
4252 .unwrap();
4253 assert_eq!(response["state"], serde_json::json!({"value": "kept"}));
4254 }
4255
4256 #[test]
4257 fn uncertain_partial_surface_preserves_complete_parsed_state() {
4258 let prepared = prepared_full_state_partial_protocol();
4259 let state = serde_json::json!({"serverOnly": [1, 2, 3], "value": "kept"});
4260 let response =
4261 render_partial(prepared.protocol(), state.clone(), "index.html", "/", "").unwrap();
4262 assert_eq!(response["state"], state);
4263 }
4264
4265 #[test]
4266 fn partial_state_validation_matches_value_number_limits() {
4267 for json in [
4268 r#"{"value":1e9999}"#,
4269 r#"{"value":-1e9999}"#,
4270 r#"{"value":1e308}"#,
4271 r#"{"value":18446744073709551615}"#,
4272 r#"{"value":18446744073709551616}"#,
4273 ] {
4274 assert_eq!(
4275 validate_json(json).is_ok(),
4276 serde_json::from_str::<Value>(json).is_ok(),
4277 "validation mismatch for {json}"
4278 );
4279 }
4280 }
4281
4282 #[test]
4283 fn test_parse_encode_inventory_roundtrip() {
4284 let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
4285 let hex = encode_inventory(&original);
4286 let decoded = parse_inventory(&hex).unwrap();
4287 assert_eq!(original, decoded);
4288 }
4289
4290 #[test]
4291 fn test_parse_inventory_empty() {
4292 let result = parse_inventory("").unwrap();
4293 assert!(result.is_empty());
4294 }
4295
4296 #[test]
4297 fn test_parse_inventory_rejects_odd_length() {
4298 let result = parse_inventory("abc");
4299 assert!(result.is_err());
4300 }
4301
4302 #[test]
4303 fn test_parse_inventory_rejects_invalid_hex() {
4304 let result = parse_inventory("zz");
4305 assert!(result.is_err());
4306 }
4307
4308 #[test]
4309 fn test_bitfield_set_and_check() {
4310 let mut inv = vec![0u8; 4];
4311 set_component(&mut inv, 0);
4312 set_component(&mut inv, 7);
4313 set_component(&mut inv, 8);
4314 set_component(&mut inv, 15);
4315 assert!(has_component(&inv, 0));
4316 assert!(has_component(&inv, 7));
4317 assert!(has_component(&inv, 8));
4318 assert!(has_component(&inv, 15));
4319 assert!(!has_component(&inv, 1));
4320 assert!(!has_component(&inv, 9));
4321 }
4322
4323 #[test]
4324 fn test_filter_needed_components_no_false_positives() {
4325 let mut index = HashMap::new();
4327 index.insert("email-message".to_string(), 0);
4328 index.insert("o-button".to_string(), 1);
4329 index.insert("o-avatar".to_string(), 2);
4330
4331 let names = vec!["email-message".to_string(), "o-button".to_string()];
4332
4333 let mut inv = vec![0u8; 1];
4335 set_component(&mut inv, 1); let inv_hex = encode_inventory(&inv);
4337
4338 let (needed, _) = filter_needed_components(&names, &inv_hex, &index).unwrap();
4339 assert_eq!(needed.len(), 1);
4340 assert!(
4341 needed.contains(&"email-message".to_string()),
4342 "email-message must be needed: {needed:?}"
4343 );
4344 }
4345
4346 #[test]
4347 fn test_get_needed_components_empty_inventory() {
4348 let mut fragments = HashMap::new();
4349 fragments.insert(
4350 "app-shell".to_string(),
4351 FragmentList {
4352 fragments: vec![WebUIFragment::component("my-card")],
4353 contains_boundary: false,
4354 },
4355 );
4356 fragments.insert(
4357 "my-card".to_string(),
4358 FragmentList {
4359 fragments: vec![WebUIFragment::raw("card")],
4360 contains_boundary: false,
4361 },
4362 );
4363
4364 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
4365 let comp_index = build_component_index(&protocol);
4366 let (needed, _inv) =
4367 get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
4368 assert!(needed.contains(&"app-shell".to_string()));
4369 assert!(needed.contains(&"my-card".to_string()));
4370 }
4371
4372 #[test]
4373 fn test_get_needed_components_skips_inventory() {
4374 let mut fragments = HashMap::new();
4375 fragments.insert(
4376 "app-shell".to_string(),
4377 FragmentList {
4378 fragments: vec![WebUIFragment::component("my-card")],
4379 contains_boundary: false,
4380 },
4381 );
4382 fragments.insert(
4383 "my-card".to_string(),
4384 FragmentList {
4385 fragments: vec![WebUIFragment::raw("card")],
4386 contains_boundary: false,
4387 },
4388 );
4389
4390 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
4391 protocol
4392 .components
4393 .entry("app-shell".to_string())
4394 .or_default()
4395 .template = "<t></t>".to_string();
4396 protocol
4397 .components
4398 .entry("my-card".to_string())
4399 .or_default()
4400 .template = "<t></t>".to_string();
4401
4402 let comp_index = build_component_index(&protocol);
4403 let (_needed, inv_hex) =
4404 get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
4405 let (needed2, _) =
4406 get_needed_components(&protocol, "app-shell", &inv_hex, &comp_index).unwrap();
4407 assert!(needed2.is_empty());
4408 }
4409
4410 #[test]
4411 fn test_get_needed_components_skips_control_fragments() {
4412 let mut fragments = HashMap::new();
4413 fragments.insert(
4414 "app-shell".to_string(),
4415 FragmentList {
4416 fragments: vec![
4417 WebUIFragment::if_cond(
4418 webui_protocol::ConditionExpr::identifier("showNav"),
4419 "if-shell",
4420 ),
4421 WebUIFragment::for_loop("item", "items", "for-items"),
4422 WebUIFragment::attribute_template("title", "attr-title"),
4423 ],
4424 contains_boundary: false,
4425 },
4426 );
4427 fragments.insert(
4428 "if-shell".to_string(),
4429 FragmentList {
4430 fragments: vec![WebUIFragment::component("mp-category-nav")],
4431 contains_boundary: false,
4432 },
4433 );
4434 fragments.insert(
4435 "for-items".to_string(),
4436 FragmentList {
4437 fragments: vec![WebUIFragment::component("mp-product-card")],
4438 contains_boundary: false,
4439 },
4440 );
4441 fragments.insert(
4442 "attr-title".to_string(),
4443 FragmentList {
4444 fragments: vec![WebUIFragment::raw("Products")],
4445 contains_boundary: false,
4446 },
4447 );
4448 fragments.insert(
4449 "mp-category-nav".to_string(),
4450 FragmentList {
4451 fragments: vec![WebUIFragment::raw("<nav></nav>")],
4452 contains_boundary: false,
4453 },
4454 );
4455 fragments.insert(
4456 "mp-product-card".to_string(),
4457 FragmentList {
4458 fragments: vec![WebUIFragment::raw("<article></article>")],
4459 contains_boundary: false,
4460 },
4461 );
4462
4463 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
4464 let comp_index = build_component_index(&protocol);
4465 let needed: HashSet<String> =
4466 get_needed_components(&protocol, "app-shell", "", &comp_index)
4467 .unwrap()
4468 .0
4469 .into_iter()
4470 .collect();
4471
4472 assert!(needed.contains("app-shell"));
4473 assert!(needed.contains("mp-category-nav"));
4474 assert!(needed.contains("mp-product-card"));
4475 assert!(!needed.contains("if-shell"));
4476 assert!(!needed.contains("for-items"));
4477 assert!(!needed.contains("attr-title"));
4478 }
4479
4480 #[test]
4481 fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
4482 let mut fragments = HashMap::new();
4483 fragments.insert(
4484 "index.html".to_string(),
4485 FragmentList {
4486 fragments: vec![WebUIFragment::component("mp-app")],
4487 contains_boundary: false,
4488 },
4489 );
4490 fragments.insert(
4491 "mp-app".to_string(),
4492 FragmentList {
4493 fragments: vec![
4494 WebUIFragment::component("mp-category-nav"),
4495 WebUIFragment::route_from(WebUiFragmentRoute {
4496 path: "/search/:category".into(),
4497 fragment_id: "mp-search-page".into(),
4498 exact: true,
4499 keep_alive: false,
4500 ..Default::default()
4501 }),
4502 WebUIFragment::route_from(WebUiFragmentRoute {
4503 path: "/product/:handle".into(),
4504 fragment_id: "mp-product-page".into(),
4505 exact: true,
4506 keep_alive: false,
4507 ..Default::default()
4508 }),
4509 ],
4510
4511 contains_boundary: false,
4512 },
4513 );
4514 fragments.insert(
4515 "mp-category-nav".to_string(),
4516 FragmentList {
4517 fragments: vec![WebUIFragment::raw("<nav></nav>")],
4518 contains_boundary: false,
4519 },
4520 );
4521 fragments.insert(
4522 "mp-search-page".to_string(),
4523 FragmentList {
4524 fragments: vec![WebUIFragment::component("mp-product-grid")],
4525 contains_boundary: false,
4526 },
4527 );
4528 fragments.insert(
4529 "mp-product-grid".to_string(),
4530 FragmentList {
4531 fragments: vec![WebUIFragment::raw("<div>grid</div>")],
4532 contains_boundary: false,
4533 },
4534 );
4535 fragments.insert(
4536 "mp-product-page".to_string(),
4537 FragmentList {
4538 fragments: vec![WebUIFragment::component("mp-product-detail")],
4539 contains_boundary: false,
4540 },
4541 );
4542 fragments.insert(
4543 "mp-product-detail".to_string(),
4544 FragmentList {
4545 fragments: vec![WebUIFragment::raw("<div>detail</div>")],
4546 contains_boundary: false,
4547 },
4548 );
4549
4550 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
4551 protocol
4552 .components
4553 .entry("mp-search-page".to_string())
4554 .or_default()
4555 .template = "<mp-search-page></mp-search-page>".to_string();
4556 protocol
4557 .components
4558 .entry("mp-product-page".to_string())
4559 .or_default()
4560 .template = "<mp-product-page></mp-product-page>".to_string();
4561
4562 let comp_index = build_component_index(&protocol);
4563 let (needed, _inv) = get_needed_components_for_request(
4564 &protocol,
4565 "index.html",
4566 "/search/shirts",
4567 "",
4568 (&comp_index, &CompiledRouteIndex::new(&protocol)),
4569 )
4570 .unwrap();
4571 let needed: HashSet<String> = needed.into_iter().collect();
4572
4573 assert!(needed.contains("mp-app"));
4574 assert!(needed.contains("mp-category-nav"));
4575 assert!(needed.contains("mp-search-page"));
4576 assert!(needed.contains("mp-product-grid"));
4577 assert!(!needed.contains("mp-product-page"));
4578 assert!(!needed.contains("mp-product-detail"));
4579 }
4580
4581 #[test]
4582 fn test_get_needed_components_for_request_follows_nested_route_chain() {
4583 let mut fragments = HashMap::new();
4584 fragments.insert(
4585 "index.html".to_string(),
4586 FragmentList {
4587 fragments: vec![WebUIFragment::component("mp-app")],
4588 contains_boundary: false,
4589 },
4590 );
4591 fragments.insert(
4592 "mp-app".to_string(),
4593 FragmentList {
4594 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
4595 path: "/account/*rest".into(),
4596 fragment_id: "mp-account-shell".into(),
4597 exact: false,
4598 keep_alive: false,
4599 ..Default::default()
4600 })],
4601
4602 contains_boundary: false,
4603 },
4604 );
4605 fragments.insert(
4606 "mp-account-shell".to_string(),
4607 FragmentList {
4608 fragments: vec![
4609 WebUIFragment::component("mp-account-nav"),
4610 WebUIFragment::route_from(WebUiFragmentRoute {
4611 path: "/account/profile".into(),
4612 fragment_id: "mp-profile-page".into(),
4613 exact: true,
4614 keep_alive: false,
4615 ..Default::default()
4616 }),
4617 WebUIFragment::route_from(WebUiFragmentRoute {
4618 path: "/account/orders/:id".into(),
4619 fragment_id: "mp-order-page".into(),
4620 exact: true,
4621 keep_alive: false,
4622 ..Default::default()
4623 }),
4624 ],
4625
4626 contains_boundary: false,
4627 },
4628 );
4629 fragments.insert(
4630 "mp-account-nav".to_string(),
4631 FragmentList {
4632 fragments: vec![WebUIFragment::raw("<nav></nav>")],
4633 contains_boundary: false,
4634 },
4635 );
4636 fragments.insert(
4637 "mp-profile-page".to_string(),
4638 FragmentList {
4639 fragments: vec![WebUIFragment::raw("<profile></profile>")],
4640 contains_boundary: false,
4641 },
4642 );
4643 fragments.insert(
4644 "mp-order-page".to_string(),
4645 FragmentList {
4646 fragments: vec![WebUIFragment::component("mp-order-detail")],
4647 contains_boundary: false,
4648 },
4649 );
4650 fragments.insert(
4651 "mp-order-detail".to_string(),
4652 FragmentList {
4653 fragments: vec![WebUIFragment::raw("<detail></detail>")],
4654 contains_boundary: false,
4655 },
4656 );
4657
4658 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
4659 protocol
4660 .components
4661 .entry("mp-account-shell".to_string())
4662 .or_default()
4663 .template = "<mp-account-shell></mp-account-shell>".to_string();
4664 protocol
4665 .components
4666 .entry("mp-profile-page".to_string())
4667 .or_default()
4668 .template = "<mp-profile-page></mp-profile-page>".to_string();
4669 protocol
4670 .components
4671 .entry("mp-order-page".to_string())
4672 .or_default()
4673 .template = "<mp-order-page></mp-order-page>".to_string();
4674
4675 let comp_index = build_component_index(&protocol);
4676 let (needed, _inv) = get_needed_components_for_request(
4677 &protocol,
4678 "index.html",
4679 "/account/orders/42",
4680 "",
4681 (&comp_index, &CompiledRouteIndex::new(&protocol)),
4682 )
4683 .unwrap();
4684 let needed: HashSet<String> = needed.into_iter().collect();
4685
4686 assert!(needed.contains("mp-app"));
4687 assert!(needed.contains("mp-account-shell"));
4688 assert!(needed.contains("mp-account-nav"));
4689 assert!(needed.contains("mp-order-page"));
4690 assert!(needed.contains("mp-order-detail"));
4691 assert!(!needed.contains("mp-profile-page"));
4692 }
4693
4694 #[test]
4695 fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
4696 let mut fragments = HashMap::new();
4697 fragments.insert(
4698 "index.html".to_string(),
4699 FragmentList {
4700 fragments: vec![WebUIFragment::component("mp-app")],
4701 contains_boundary: false,
4702 },
4703 );
4704 fragments.insert(
4705 "mp-app".to_string(),
4706 FragmentList {
4707 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
4708 path: "/search".into(),
4709 fragment_id: "mp-search-page".into(),
4710 exact: true,
4711 keep_alive: false,
4712 ..Default::default()
4713 })],
4714
4715 contains_boundary: false,
4716 },
4717 );
4718 fragments.insert(
4719 "mp-search-page".to_string(),
4720 FragmentList {
4721 fragments: vec![
4722 WebUIFragment::if_cond(
4723 webui_protocol::ConditionExpr::identifier("showFilters"),
4724 "if-filters",
4725 ),
4726 WebUIFragment::for_loop("item", "items", "item-loop"),
4727 ],
4728 contains_boundary: false,
4729 },
4730 );
4731 fragments.insert(
4732 "if-filters".to_string(),
4733 FragmentList {
4734 fragments: vec![WebUIFragment::component("mp-filter-panel")],
4735 contains_boundary: false,
4736 },
4737 );
4738 fragments.insert(
4739 "item-loop".to_string(),
4740 FragmentList {
4741 fragments: vec![WebUIFragment::component("mp-item-card")],
4742 contains_boundary: false,
4743 },
4744 );
4745 fragments.insert(
4746 "mp-filter-panel".to_string(),
4747 FragmentList {
4748 fragments: vec![WebUIFragment::raw("<filters></filters>")],
4749 contains_boundary: false,
4750 },
4751 );
4752 fragments.insert(
4753 "mp-item-card".to_string(),
4754 FragmentList {
4755 fragments: vec![WebUIFragment::raw("<item></item>")],
4756 contains_boundary: false,
4757 },
4758 );
4759
4760 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
4761 protocol
4762 .components
4763 .entry("mp-search-page".to_string())
4764 .or_default()
4765 .template = "<mp-search-page></mp-search-page>".to_string();
4766
4767 let comp_index = build_component_index(&protocol);
4768 let (needed, _inv) = get_needed_components_for_request(
4769 &protocol,
4770 "index.html",
4771 "/search",
4772 "",
4773 (&comp_index, &CompiledRouteIndex::new(&protocol)),
4774 )
4775 .unwrap();
4776 let needed: HashSet<String> = needed.into_iter().collect();
4777
4778 assert!(needed.contains("mp-app"));
4779 assert!(needed.contains("mp-search-page"));
4780 assert!(needed.contains("mp-filter-panel"));
4781 assert!(needed.contains("mp-item-card"));
4782 }
4783
4784 #[test]
4785 fn test_get_needed_components_for_request_respects_inventory() {
4786 let mut fragments = HashMap::new();
4787 fragments.insert(
4788 "index.html".to_string(),
4789 FragmentList {
4790 fragments: vec![WebUIFragment::component("mp-app")],
4791 contains_boundary: false,
4792 },
4793 );
4794 fragments.insert(
4795 "mp-app".to_string(),
4796 FragmentList {
4797 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
4798 path: "/search".into(),
4799 fragment_id: "mp-search-page".into(),
4800 exact: true,
4801 keep_alive: false,
4802 ..Default::default()
4803 })],
4804
4805 contains_boundary: false,
4806 },
4807 );
4808 fragments.insert(
4809 "mp-search-page".to_string(),
4810 FragmentList {
4811 fragments: vec![WebUIFragment::component("mp-product-grid")],
4812 contains_boundary: false,
4813 },
4814 );
4815 fragments.insert(
4816 "mp-product-grid".to_string(),
4817 FragmentList {
4818 fragments: vec![WebUIFragment::raw("<grid></grid>")],
4819 contains_boundary: false,
4820 },
4821 );
4822
4823 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
4824 for name in ["mp-app", "mp-search-page", "mp-product-grid"] {
4825 protocol
4826 .components
4827 .entry(name.to_string())
4828 .or_default()
4829 .template = format!("<f-template id=\"{name}\"></f-template>");
4830 }
4831
4832 let comp_index = build_component_index(&protocol);
4833 let route_index = CompiledRouteIndex::new(&protocol);
4834 let (_needed, inventory) = get_needed_components_for_request(
4835 &protocol,
4836 "index.html",
4837 "/search",
4838 "",
4839 (&comp_index, &route_index),
4840 )
4841 .unwrap();
4842 let (needed_again, _) = get_needed_components_for_request(
4843 &protocol,
4844 "index.html",
4845 "/search",
4846 &inventory,
4847 (&comp_index, &route_index),
4848 )
4849 .unwrap();
4850 assert!(needed_again.is_empty());
4851 }
4852
4853 #[test]
4854 fn test_inventory_includes_boundary_components_for_unmatched_sibling_routes() {
4855 let mut fragments = HashMap::new();
4861 fragments.insert(
4862 "index.html".to_string(),
4863 FragmentList {
4864 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
4865 path: "/".into(),
4866 fragment_id: "route-shell".into(),
4867 children: vec![
4868 WebUiFragmentRoute {
4869 path: "slow".into(),
4870 fragment_id: "page-slow".into(),
4871 exact: true,
4872 pending_component: "loading-skeleton".into(),
4873 ..Default::default()
4874 },
4875 WebUiFragmentRoute {
4876 path: "failing".into(),
4877 fragment_id: "page-failing".into(),
4878 exact: true,
4879 error_component: "error-display".into(),
4880 ..Default::default()
4881 },
4882 ],
4883 ..Default::default()
4884 })],
4885
4886 contains_boundary: false,
4887 },
4888 );
4889 for id in [
4890 "route-shell",
4891 "page-slow",
4892 "page-failing",
4893 "loading-skeleton",
4894 "error-display",
4895 ] {
4896 fragments.insert(
4897 id.to_string(),
4898 FragmentList {
4899 fragments: vec![WebUIFragment::raw("<x></x>")],
4900 contains_boundary: false,
4901 },
4902 );
4903 }
4904
4905 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
4906 for id in [
4907 "route-shell",
4908 "page-slow",
4909 "page-failing",
4910 "loading-skeleton",
4911 "error-display",
4912 ] {
4913 protocol
4914 .components
4915 .entry(id.to_string())
4916 .or_default()
4917 .template = format!("<f-template id=\"{id}\"></f-template>");
4918 }
4919
4920 let reachable = collect_reachable_component_order_for_request(
4922 &protocol,
4923 "index.html",
4924 "/",
4925 &CompiledRouteIndex::new(&protocol),
4926 );
4927 assert!(
4928 reachable.iter().any(|name| name == "loading-skeleton"),
4929 "pending component of an unmatched sibling route must be inventoried: {reachable:?}"
4930 );
4931 assert!(
4932 reachable.iter().any(|name| name == "error-display"),
4933 "error component of an unmatched sibling route must be inventoried: {reachable:?}"
4934 );
4935 }
4936
4937 #[test]
4938 fn component_reachability_propagates_route_dependency_to_parent() {
4939 let protocol = WebUIProtocol::new(HashMap::from([
4940 (
4941 "comp-a".to_string(),
4942 FragmentList {
4943 fragments: vec![WebUIFragment::component("comp-b")],
4944 contains_boundary: false,
4945 },
4946 ),
4947 (
4948 "comp-b".to_string(),
4949 FragmentList {
4950 fragments: vec![WebUIFragment::route("/", "page-a")],
4951 contains_boundary: false,
4952 },
4953 ),
4954 (
4955 "page-a".to_string(),
4956 FragmentList {
4957 fragments: vec![WebUIFragment::raw("<p>page</p>")],
4958 contains_boundary: false,
4959 },
4960 ),
4961 ]));
4962 let component_index = build_component_index(&protocol);
4963 let reachability = ComponentReachabilityIndex::new(&protocol, &component_index);
4964
4965 assert_eq!(
4966 reachability.is_route_dependent(component_index["comp-a"]),
4967 Some(true)
4968 );
4969 assert_eq!(
4970 reachability.is_route_dependent(component_index["comp-b"]),
4971 Some(true)
4972 );
4973 assert_eq!(
4974 reachability.is_route_dependent(component_index["page-a"]),
4975 Some(false)
4976 );
4977 }
4978
4979 #[test]
4980 fn request_reachability_visits_reused_route_component_at_each_base() {
4981 let mut protocol = WebUIProtocol::new(HashMap::from([
4982 (
4983 "shared-shell".to_string(),
4984 FragmentList {
4985 fragments: vec![WebUIFragment::route("details", "detail-page")],
4986 contains_boundary: false,
4987 },
4988 ),
4989 (
4990 "detail-page".to_string(),
4991 FragmentList {
4992 fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
4993 contains_boundary: false,
4994 },
4995 ),
4996 ]));
4997 for tag in ["shared-shell", "detail-page"] {
4998 protocol
4999 .components
5000 .entry(tag.to_string())
5001 .or_default()
5002 .template = "<template></template>".to_string();
5003 }
5004 let route_index = CompiledRouteIndex::new(&protocol);
5005 let handler_protocol = Protocol::new(protocol.clone());
5006 let reachability = handler_protocol.component_reachability();
5007 let shell = handler_protocol.component_index()["shared-shell"];
5008 let roots = [
5009 (shell, Some(Box::from("/other"))),
5010 (shell, Some(Box::from("/account"))),
5011 ];
5012
5013 let reachable = collect_reachable_components_from_roots(
5014 &protocol,
5015 &roots,
5016 reachability,
5017 "/account/details",
5018 &route_index,
5019 );
5020
5021 assert!(
5022 reachable.iter().any(|tag| tag == "detail-page"),
5023 "the second route base must not be hidden by fragment-id deduplication: {reachable:?}"
5024 );
5025 }
5026
5027 #[test]
5028 fn component_reachability_index_is_lazy() {
5029 let protocol = Protocol::new(WebUIProtocol::new(HashMap::from([(
5030 "comp-a".to_string(),
5031 FragmentList {
5032 fragments: vec![WebUIFragment::raw("<p>leaf</p>")],
5033 contains_boundary: false,
5034 },
5035 )])));
5036
5037 assert!(protocol.component_reachability.get().is_none());
5038 assert_eq!(
5039 protocol
5040 .component_reachability()
5041 .is_route_dependent(protocol.component_index["comp-a"]),
5042 Some(false)
5043 );
5044 assert!(protocol.component_reachability.get().is_some());
5045 }
5046
5047 #[test]
5048 fn test_render_partial_carries_module_css_in_component_styles() {
5049 let mut fragments = HashMap::new();
5050 fragments.insert(
5051 "index.html".to_string(),
5052 FragmentList {
5053 fragments: vec![WebUIFragment::component("my-page")],
5054 contains_boundary: false,
5055 },
5056 );
5057 fragments.insert(
5058 "my-page".to_string(),
5059 FragmentList {
5060 fragments: vec![WebUIFragment::raw("<p>page</p>")],
5061 contains_boundary: false,
5062 },
5063 );
5064
5065 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
5066 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
5067 let component = protocol
5068 .components
5069 .entry("my-page".to_string())
5070 .or_default();
5071 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
5072 component.css = ".page{color:red}".to_string();
5073 protocol.populate_style_closures(&["index.html"]);
5074
5075 let mut index = ProtocolIndex::new(&protocol);
5076 let partial =
5077 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
5078 assert_eq!(partial["componentStyles"]["version"], 1);
5079 assert_eq!(partial["componentStyles"]["strategy"], "module");
5080 assert_eq!(
5081 partial["componentStyles"]["resources"]["my-page"],
5082 serde_json::json!({
5083 "kind": "module",
5084 "specifier": "my-page",
5085 "css": ".page{color:red}"
5086 })
5087 );
5088 assert_eq!(
5089 partial["componentStyles"]["closures"]["my-page"],
5090 serde_json::json!(["my-page"])
5091 );
5092
5093 let templates = partial["templates"]
5095 .as_object()
5096 .expect("templates should be an object");
5097 assert_eq!(templates.len(), 1);
5098 let template = templates.get("my-page").expect("my-page template");
5099 assert_eq!(template["h"], "<p>page</p>");
5100
5101 let inventory = partial["inventory"].as_str().expect("inventory");
5102 let repeated =
5103 render_partial_metadata(&protocol, "index.html", "/", inventory, &mut index).unwrap();
5104 assert!(repeated["templates"].as_object().unwrap().is_empty());
5105 assert_eq!(
5106 repeated["componentStyles"]["resources"],
5107 serde_json::json!({}),
5108 "inventoried style definitions must not be retransmitted"
5109 );
5110 assert_eq!(
5111 repeated["componentStyles"]["closures"],
5112 serde_json::json!({}),
5113 "inventoried style closures must not be retransmitted"
5114 );
5115 }
5116
5117 #[test]
5118 fn styled_protocol_without_closures_is_rejected() {
5119 let mut protocol = WebUIProtocol::default();
5120 protocol.fragments = HashMap::from([
5121 (
5122 "index.html".to_string(),
5123 FragmentList {
5124 fragments: vec![WebUIFragment::component("legacy-page")],
5125 contains_boundary: false,
5126 },
5127 ),
5128 (
5129 "legacy-page".to_string(),
5130 FragmentList {
5131 fragments: vec![WebUIFragment::raw("<p>Legacy</p>")],
5132 contains_boundary: false,
5133 },
5134 ),
5135 ]);
5136 protocol.components.insert(
5137 "legacy-page".to_string(),
5138 webui_protocol::ComponentData {
5139 template_json: r#"{"h":"<p>Legacy</p>"}"#.to_string(),
5140 css: ".legacy{display:block}".to_string(),
5141 ..Default::default()
5142 },
5143 );
5144 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
5145
5146 let mut index = ProtocolIndex::new(&protocol);
5147 let error = render_partial_metadata(&protocol, "index.html", "/", "", &mut index)
5148 .expect_err("styled protocols require closure metadata");
5149 assert!(error
5150 .to_string()
5151 .contains("component style closure metadata is required"));
5152 }
5153
5154 #[test]
5155 fn test_render_partial_link_strategy_uses_component_styles() {
5156 let mut fragments = HashMap::new();
5157 fragments.insert(
5158 "index.html".to_string(),
5159 FragmentList {
5160 fragments: vec![WebUIFragment::component("my-page")],
5161 contains_boundary: false,
5162 },
5163 );
5164 fragments.insert(
5165 "my-page".to_string(),
5166 FragmentList {
5167 fragments: vec![WebUIFragment::raw("<p>page</p>")],
5168 contains_boundary: false,
5169 },
5170 );
5171
5172 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
5173 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
5174 let component = protocol
5175 .components
5176 .entry("my-page".to_string())
5177 .or_default();
5178 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
5179 component.css_href = "my-page.css".to_string();
5180 protocol.populate_style_closures(&["index.html"]);
5182
5183 let mut index = ProtocolIndex::new(&protocol);
5184 let partial =
5185 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
5186 let templates = partial["templates"]
5187 .as_object()
5188 .expect("templates should be an object");
5189
5190 assert_eq!(templates.len(), 1, "should include template metadata");
5191 assert_eq!(
5192 partial["componentStyles"]["resources"]["my-page"],
5193 serde_json::json!({"kind": "link", "href": "my-page.css"})
5194 );
5195 }
5196
5197 #[test]
5198 fn test_render_partial_style_strategy_uses_component_styles() {
5199 let mut fragments = HashMap::new();
5200 fragments.insert(
5201 "index.html".to_string(),
5202 FragmentList {
5203 fragments: vec![WebUIFragment::component("my-page")],
5204 contains_boundary: false,
5205 },
5206 );
5207 fragments.insert(
5208 "my-page".to_string(),
5209 FragmentList {
5210 fragments: vec![WebUIFragment::raw("<p>page</p>")],
5211 contains_boundary: false,
5212 },
5213 );
5214
5215 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
5216 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
5217 let component = protocol
5218 .components
5219 .entry("my-page".to_string())
5220 .or_default();
5221 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
5222 component.css = ".p{color:red}".to_string();
5223 protocol.populate_style_closures(&["index.html"]);
5224
5225 let mut index = ProtocolIndex::new(&protocol);
5226 let partial =
5227 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
5228 let templates = partial["templates"]
5229 .as_object()
5230 .expect("templates should be an object");
5231
5232 assert_eq!(templates.len(), 1, "should include template metadata");
5233 assert_eq!(
5234 partial["componentStyles"]["resources"]["my-page"],
5235 serde_json::json!({"kind": "style", "css": ".p{color:red}"})
5236 );
5237 }
5238
5239 #[test]
5240 fn test_render_partial_emits_empty_component_styles_for_no_css_components() {
5241 let mut fragments = HashMap::new();
5242 fragments.insert(
5243 "index.html".to_string(),
5244 FragmentList {
5245 fragments: vec![WebUIFragment::component("my-page")],
5246 contains_boundary: false,
5247 },
5248 );
5249 fragments.insert(
5250 "my-page".to_string(),
5251 FragmentList {
5252 fragments: vec![WebUIFragment::raw("<p>page</p>")],
5253 contains_boundary: false,
5254 },
5255 );
5256
5257 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
5258 let component = protocol
5259 .components
5260 .entry("my-page".to_string())
5261 .or_default();
5262 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
5263 let mut index = ProtocolIndex::new(&protocol);
5266 let partial =
5267 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
5268 assert_eq!(
5269 partial["componentStyles"]["resources"],
5270 serde_json::json!({})
5271 );
5272 assert_eq!(
5273 partial["componentStyles"]["closures"],
5274 serde_json::json!({})
5275 );
5276 }
5277
5278 #[test]
5279 fn test_render_partial_filters_styles_with_template_inventory() {
5280 let mut fragments = HashMap::new();
5281 fragments.insert(
5282 "index.html".to_string(),
5283 FragmentList {
5284 fragments: vec![WebUIFragment::component("my-page")],
5285 contains_boundary: false,
5286 },
5287 );
5288 fragments.insert(
5289 "my-page".to_string(),
5290 FragmentList {
5291 fragments: vec![WebUIFragment::raw("<p>page</p>")],
5292 contains_boundary: false,
5293 },
5294 );
5295
5296 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
5297 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
5298 let component = protocol
5299 .components
5300 .entry("my-page".to_string())
5301 .or_default();
5302 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
5303 component.css = ".page{color:red}".to_string();
5304 protocol.populate_style_closures(&["index.html"]);
5305
5306 let mut index = ProtocolIndex::new(&protocol);
5307 let partial1 =
5309 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
5310 let inv = partial1["inventory"].as_str().unwrap_or_default();
5311 assert!(!inv.is_empty());
5312
5313 let partial2 =
5314 render_partial_metadata(&protocol, "index.html", "/", inv, &mut index).unwrap();
5315 let templates = partial2["templates"]
5316 .as_object()
5317 .expect("templates should be an object");
5318
5319 assert!(
5320 templates.is_empty(),
5321 "templates should be empty when inventory is full"
5322 );
5323 assert_eq!(
5324 partial2["componentStyles"]["resources"],
5325 serde_json::json!({})
5326 );
5327 assert_eq!(
5328 partial2["componentStyles"]["closures"],
5329 serde_json::json!({})
5330 );
5331 }
5332
5333 #[test]
5334 fn component_style_delta_sends_missing_and_reuses_registered_closure_resources() {
5335 let mut protocol = WebUIProtocol::default();
5336 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
5337 for tag in ["page-b", "shared-card"] {
5338 protocol.fragments.insert(
5339 tag.to_string(),
5340 FragmentList {
5341 fragments: Vec::new(),
5342 contains_boundary: false,
5343 },
5344 );
5345 protocol.components.insert(
5346 tag.to_string(),
5347 webui_protocol::ComponentData {
5348 css: format!(".{tag}{{display:block}}"),
5349 ..Default::default()
5350 },
5351 );
5352 }
5353 protocol.style_closures.insert(
5354 "page-b".to_string(),
5355 webui_protocol::ComponentStyleClosure {
5356 component_tags: vec!["page-b".to_string(), "shared-card".to_string()],
5357 style_chunks: Vec::new(),
5358 },
5359 );
5360 protocol.style_closures.insert(
5361 "shared-card".to_string(),
5362 webui_protocol::ComponentStyleClosure {
5363 component_tags: vec!["shared-card".to_string()],
5364 style_chunks: Vec::new(),
5365 },
5366 );
5367 let mut index = ProtocolIndex::new(&protocol);
5368
5369 let first_page =
5370 render_component_templates(&protocol, &["page-b"], "", &mut index).unwrap();
5371 assert!(first_page["componentStyles"]["resources"]["page-b"].is_object());
5372 assert!(
5373 first_page["componentStyles"]["resources"]["shared-card"].is_object(),
5374 "a missing closure dependency must be sent with its new root"
5375 );
5376
5377 let shared =
5378 render_component_templates(&protocol, &["shared-card"], "", &mut index).unwrap();
5379 let shared_inventory = shared["inventory"].as_str().unwrap_or_default();
5380 let page = render_component_templates(&protocol, &["page-b"], shared_inventory, &mut index)
5381 .unwrap();
5382 assert_eq!(
5383 page["componentStyles"]["closures"]["page-b"],
5384 serde_json::json!(["page-b", "shared-card"])
5385 );
5386 assert!(page["componentStyles"]["resources"]["page-b"].is_object());
5387 assert!(
5388 page["componentStyles"]["resources"]
5389 .get("shared-card")
5390 .is_none(),
5391 "a resource already covered by inventory must not be retransmitted"
5392 );
5393 }
5394
5395 #[test]
5396 fn bundled_style_metadata_tracks_chunks_independently_and_covers_members() {
5397 let mut protocol = WebUIProtocol::default();
5398 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
5399 for tag in ["a-card", "b-card"] {
5400 protocol
5401 .fragments
5402 .insert(tag.to_string(), FragmentList::default());
5403 protocol.components.insert(
5404 tag.to_string(),
5405 webui_protocol::ComponentData {
5406 css: format!(".{tag}{{display:block}}"),
5407 ..Default::default()
5408 },
5409 );
5410 protocol.style_closures.insert(
5411 tag.to_string(),
5412 webui_protocol::ComponentStyleClosure {
5413 component_tags: vec![tag.to_string()],
5414 style_chunks: Vec::new(),
5415 },
5416 );
5417 }
5418 protocol
5419 .fragments
5420 .insert("index.html".to_string(), FragmentList::default());
5421 protocol.style_closures.insert(
5422 "index.html".to_string(),
5423 webui_protocol::ComponentStyleClosure {
5424 component_tags: vec!["a-card".to_string(), "b-card".to_string()],
5425 style_chunks: vec![0],
5426 },
5427 );
5428 protocol.style_chunks.push(webui_protocol::StyleChunk {
5429 name: "_chunk-a-card-2".to_string(),
5430 css: ".a-card{display:block}\n.b-card{display:block}".to_string(),
5431 css_href: String::new(),
5432 component_tags: vec!["a-card".to_string(), "b-card".to_string()],
5433 });
5434
5435 let initial =
5436 collect_component_styles(&protocol, ["index.html", "a-card", "b-card"]).unwrap();
5437 assert_eq!(
5438 initial["resources"]["_chunk-a-card-2"]["members"],
5439 serde_json::json!(["a-card", "b-card"])
5440 );
5441 assert_eq!(
5442 initial["closures"],
5443 serde_json::json!({ "index.html": ["_chunk-a-card-2"] }),
5444 "member closures already covered by the requested tree must not duplicate CSS"
5445 );
5446 assert_eq!(
5447 initial["resources"].as_object().map(serde_json::Map::len),
5448 Some(1)
5449 );
5450
5451 let component_index = build_component_index(&protocol);
5452 let mut component_inventory = vec![0u8; component_index.len().div_ceil(8)];
5453 for index in component_index.values() {
5454 set_component(&mut component_inventory, *index);
5455 }
5456 let from_component_inventory = collect_component_styles_for_inventory(
5457 &protocol,
5458 ["index.html"],
5459 &component_inventory,
5460 &component_index,
5461 )
5462 .unwrap();
5463 assert!(
5464 from_component_inventory["resources"]["_chunk-a-card-2"].is_object(),
5465 "component-template bits do not prove that the client registered a chunk"
5466 );
5467
5468 let style_index = build_style_resource_index(&protocol);
5469 let mut style_inventory = vec![0u8; style_index.len().div_ceil(8)];
5470 set_component(&mut style_inventory, style_index["_chunk-a-card-2"]);
5471 let from_style_inventory = collect_component_style_delta(
5472 &protocol,
5473 ["index.html"],
5474 &style_inventory,
5475 &style_index,
5476 )
5477 .unwrap();
5478 assert_eq!(
5479 from_style_inventory["resources"],
5480 serde_json::json!({}),
5481 "the exact style-resource inventory should suppress a registered chunk"
5482 );
5483 let chunk_index = protocol.style_chunk_index();
5484 let borrowed = collect_borrowed_component_style_delta(
5485 &protocol,
5486 ["index.html"],
5487 &style_inventory,
5488 &style_index,
5489 &chunk_index,
5490 )
5491 .unwrap();
5492 assert_eq!(
5493 serde_json::to_string(&borrowed).unwrap(),
5494 serde_json::to_string(&from_style_inventory).unwrap(),
5495 "the borrowed streaming serializer must preserve the canonical style payload bytes"
5496 );
5497 }
5498
5499 #[test]
5500 fn borrowed_streaming_style_payload_matches_owned_for_every_strategy() {
5501 for strategy in [
5502 webui_protocol::CssStrategy::Link,
5503 webui_protocol::CssStrategy::Style,
5504 webui_protocol::CssStrategy::Module,
5505 ] {
5506 let mut protocol = WebUIProtocol::default();
5507 protocol.set_css_strategy(strategy);
5508 protocol
5509 .fragments
5510 .insert("index.html".to_string(), FragmentList::default());
5511 protocol.components.insert(
5512 "a-card".to_string(),
5513 webui_protocol::ComponentData {
5514 css: ".a-card{display:block}".to_string(),
5515 css_href: "/a-card.css".to_string(),
5516 ..Default::default()
5517 },
5518 );
5519 protocol.style_closures.insert(
5520 "index.html".to_string(),
5521 webui_protocol::ComponentStyleClosure {
5522 component_tags: vec!["a-card".to_string()],
5523 style_chunks: Vec::new(),
5524 },
5525 );
5526
5527 let style_index = build_component_index(&protocol);
5528 let style_inventory = vec![0u8; style_index.len().div_ceil(8)];
5529 let owned = collect_component_style_delta(
5530 &protocol,
5531 ["index.html"],
5532 &style_inventory,
5533 &style_index,
5534 )
5535 .unwrap();
5536 let chunk_index = protocol.style_chunk_index();
5537 let borrowed = collect_borrowed_component_style_delta(
5538 &protocol,
5539 ["index.html"],
5540 &style_inventory,
5541 &style_index,
5542 &chunk_index,
5543 )
5544 .unwrap();
5545
5546 assert_eq!(
5547 serde_json::to_string(&borrowed).unwrap(),
5548 serde_json::to_string(&owned).unwrap(),
5549 "borrowed payload differs for {strategy:?}"
5550 );
5551 }
5552 }
5553
5554 #[test]
5563 fn member_closures_deliver_the_chunk_that_covers_them() {
5564 let mut protocol = WebUIProtocol::default();
5565 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
5566 for tag in ["a-card", "b-card"] {
5567 protocol
5568 .fragments
5569 .insert(tag.to_string(), FragmentList::default());
5570 protocol.components.insert(
5571 tag.to_string(),
5572 webui_protocol::ComponentData {
5573 css: format!(".{tag}{{display:block}}"),
5574 ..Default::default()
5575 },
5576 );
5577 }
5578 protocol
5579 .fragments
5580 .insert("lazy-panel".to_string(), FragmentList::default());
5581 protocol.style_closures.insert(
5583 "lazy-panel".to_string(),
5584 webui_protocol::ComponentStyleClosure {
5585 component_tags: vec!["a-card".to_string(), "b-card".to_string()],
5586 style_chunks: Vec::new(),
5587 },
5588 );
5589 protocol.style_chunks.push(webui_protocol::StyleChunk {
5590 name: "_chunk-a-card-2".to_string(),
5591 css: ".a-card{display:block}\n.b-card{display:block}".to_string(),
5592 css_href: String::new(),
5593 component_tags: vec!["a-card".to_string(), "b-card".to_string()],
5594 });
5595
5596 let styles = collect_component_styles(&protocol, ["lazy-panel"]).unwrap();
5597 assert_eq!(
5598 styles["closures"],
5599 serde_json::json!({ "lazy-panel": ["_chunk-a-card-2"] }),
5600 "both members resolve to one chunk, named once"
5601 );
5602 assert_eq!(
5603 styles["resources"].as_object().map(serde_json::Map::len),
5604 Some(1),
5605 "the per-member stylesheets the chunk already contains are not re-shipped"
5606 );
5607 assert!(
5608 styles["resources"]["_chunk-a-card-2"].is_object(),
5609 "the closure carries the chunk itself, not a dependency on another closure"
5610 );
5611 }
5612
5613 #[test]
5615 fn member_closures_fall_back_when_no_chunk_covers_them() {
5616 let mut protocol = WebUIProtocol::default();
5617 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
5618 for tag in ["a-card", "solo-card"] {
5619 protocol
5620 .fragments
5621 .insert(tag.to_string(), FragmentList::default());
5622 protocol.components.insert(
5623 tag.to_string(),
5624 webui_protocol::ComponentData {
5625 css: format!(".{tag}{{display:block}}"),
5626 ..Default::default()
5627 },
5628 );
5629 }
5630 protocol
5631 .fragments
5632 .insert("lazy-panel".to_string(), FragmentList::default());
5633 protocol.style_closures.insert(
5634 "lazy-panel".to_string(),
5635 webui_protocol::ComponentStyleClosure {
5636 component_tags: vec!["a-card".to_string(), "solo-card".to_string()],
5637 style_chunks: Vec::new(),
5638 },
5639 );
5640 protocol.style_chunks.push(webui_protocol::StyleChunk {
5641 name: "_chunk-a-card-1".to_string(),
5642 css: ".a-card{display:block}".to_string(),
5643 css_href: String::new(),
5644 component_tags: vec!["a-card".to_string()],
5645 });
5646
5647 let styles = collect_component_styles(&protocol, ["lazy-panel"]).unwrap();
5648 assert_eq!(
5649 styles["closures"],
5650 serde_json::json!({ "lazy-panel": ["_chunk-a-card-1", "solo-card"] }),
5651 "cascade order is preserved across the chunked and unchunked members"
5652 );
5653 assert!(styles["resources"]["solo-card"].is_object());
5654 }
5655
5656 #[test]
5657 fn test_non_route_siblings_included_in_needed_components() {
5658 let mut fragments = HashMap::new();
5663
5664 fragments.insert(
5666 "index.html".to_string(),
5667 FragmentList {
5668 fragments: vec![WebUIFragment::component("app-shell")],
5669 contains_boundary: false,
5670 },
5671 );
5672
5673 fragments.insert(
5675 "app-shell".to_string(),
5676 FragmentList {
5677 fragments: vec![
5678 WebUIFragment::component("my-navbar"),
5679 WebUIFragment::route_from(WebUiFragmentRoute {
5680 path: "/about".into(),
5681 fragment_id: "page-about".into(),
5682 exact: true,
5683 keep_alive: false,
5684 ..Default::default()
5685 }),
5686 WebUIFragment::component("cart-panel"),
5687 ],
5688
5689 contains_boundary: false,
5690 },
5691 );
5692
5693 fragments.insert(
5695 "my-navbar".to_string(),
5696 FragmentList {
5697 fragments: vec![WebUIFragment::raw("<nav/>")],
5698 contains_boundary: false,
5699 },
5700 );
5701 fragments.insert(
5702 "page-about".to_string(),
5703 FragmentList {
5704 fragments: vec![WebUIFragment::raw("<h1>About</h1>")],
5705 contains_boundary: false,
5706 },
5707 );
5708 fragments.insert(
5709 "cart-panel".to_string(),
5710 FragmentList {
5711 fragments: vec![WebUIFragment::raw("<aside>Cart</aside>")],
5712 contains_boundary: false,
5713 },
5714 );
5715
5716 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
5717 for name in ["app-shell", "my-navbar", "page-about", "cart-panel"] {
5718 let comp = protocol.components.entry(name.to_string()).or_default();
5719 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
5720 comp.css = format!(".{name}{{display:block}}");
5721 }
5722
5723 let comp_index = build_component_index(&protocol);
5724 let (needed, _inv) = get_needed_components_for_request(
5725 &protocol,
5726 "index.html",
5727 "/about",
5728 "",
5729 (&comp_index, &CompiledRouteIndex::new(&protocol)),
5730 )
5731 .unwrap();
5732
5733 assert!(
5734 needed.contains(&"app-shell".to_string()),
5735 "app-shell should be needed: {needed:?}"
5736 );
5737 assert!(
5738 needed.contains(&"my-navbar".to_string()),
5739 "my-navbar (non-route sibling) should be needed: {needed:?}"
5740 );
5741 assert!(
5742 needed.contains(&"page-about".to_string()),
5743 "page-about (active route) should be needed: {needed:?}"
5744 );
5745 assert!(
5746 needed.contains(&"cart-panel".to_string()),
5747 "cart-panel (non-route sibling) should be needed: {needed:?}"
5748 );
5749 }
5750
5751 #[test]
5754 fn test_chain_entry_to_json_includes_allowed_query() {
5755 let entry = RouteChainEntry {
5756 component: "compose-page".into(),
5757 path: "compose".into(),
5758 params: HashMap::new(),
5759 exact: true,
5760 allowed_query: "action,to,subject".into(),
5761 keep_alive: false,
5762 cache_tags: Vec::new(),
5763 invalidates: Vec::new(),
5764 pending_component: String::new(),
5765 error_component: String::new(),
5766 };
5767 let json = entry.to_json();
5768 assert_eq!(json["allowedQuery"], "action,to,subject");
5769 }
5770
5771 #[test]
5772 fn test_chain_entry_to_json_omits_empty_allowed_query() {
5773 let entry = RouteChainEntry {
5774 component: "home-page".into(),
5775 path: "/".into(),
5776 params: HashMap::new(),
5777 exact: true,
5778 allowed_query: String::new(),
5779 keep_alive: false,
5780 cache_tags: Vec::new(),
5781 invalidates: Vec::new(),
5782 pending_component: String::new(),
5783 error_component: String::new(),
5784 };
5785 let json = entry.to_json();
5786 assert!(
5787 json.get("allowedQuery").is_none(),
5788 "empty allowed_query should not appear in JSON"
5789 );
5790 }
5791
5792 #[test]
5793 fn test_collect_route_chain_carries_allowed_query() {
5794 let mut fragments = HashMap::new();
5795 fragments.insert(
5796 "index.html".to_string(),
5797 FragmentList {
5798 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
5799 path: "/".into(),
5800 fragment_id: "app-shell".into(),
5801 exact: false,
5802 children: vec![WebUiFragmentRoute {
5803 path: "compose".into(),
5804 fragment_id: "compose-page".into(),
5805 exact: true,
5806 allowed_query: "action,to".into(),
5807 keep_alive: false,
5808 ..Default::default()
5809 }],
5810 keep_alive: false,
5811 ..Default::default()
5812 })],
5813
5814 contains_boundary: false,
5815 },
5816 );
5817 fragments.insert(
5818 "app-shell".to_string(),
5819 FragmentList {
5820 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
5821 contains_boundary: false,
5822 },
5823 );
5824 fragments.insert(
5825 "compose-page".to_string(),
5826 FragmentList {
5827 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
5828 contains_boundary: false,
5829 },
5830 );
5831 let mut protocol = WebUIProtocol::new(fragments);
5832 protocol
5833 .components
5834 .entry("app-shell".into())
5835 .or_default()
5836 .css_href = "/app-shell.css".into();
5837 protocol
5838 .components
5839 .entry("compose-page".into())
5840 .or_default()
5841 .template_json = r#"{"h":"<p>Compose</p>"}"#.into();
5842
5843 let chain = collect_route_chain(
5844 &protocol,
5845 "index.html",
5846 "/compose",
5847 &CompiledRouteIndex::new(&protocol),
5848 );
5849 assert_eq!(chain.len(), 2);
5850 assert_eq!(chain[0].component, "app-shell");
5851 assert!(chain[0].allowed_query.is_empty());
5852 assert_eq!(chain[1].component, "compose-page");
5853 assert_eq!(chain[1].allowed_query, "action,to");
5854 }
5855
5856 #[test]
5857 fn scriptless_partial_uses_navigation_keys_without_bootstrap_keys() {
5858 let mut fragments = HashMap::new();
5859 fragments.insert(
5860 "index.html".to_string(),
5861 FragmentList {
5862 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
5863 path: "/items".into(),
5864 fragment_id: "items-page".into(),
5865 exact: true,
5866 ..Default::default()
5867 })],
5868
5869 contains_boundary: false,
5870 },
5871 );
5872 fragments.insert(
5873 "items-page".to_string(),
5874 FragmentList {
5875 fragments: vec![WebUIFragment::raw("<p>Items</p>")],
5876 contains_boundary: false,
5877 },
5878 );
5879 let mut protocol = WebUIProtocol::new(fragments);
5880 protocol.components.insert(
5881 "items-page".to_string(),
5882 webui_protocol::ComponentData {
5883 template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
5884 navigation_mode: Some(webui_protocol::StateProjectionMode::Keys as i32),
5885 navigation_keys: vec!["items".into(), "title".into()],
5886 ..Default::default()
5887 },
5888 );
5889
5890 let partial = render_partial(
5891 &protocol,
5892 serde_json::json!({
5893 "items": [1, 2],
5894 "serverOnly": "secret",
5895 "title": "Catalog",
5896 }),
5897 "index.html",
5898 "/items",
5899 "",
5900 )
5901 .unwrap();
5902
5903 assert_eq!(
5904 partial["state"],
5905 serde_json::json!({
5906 "items": [1, 2],
5907 "title": "Catalog",
5908 })
5909 );
5910 }
5911
5912 #[test]
5913 fn partial_requires_explicit_navigation_keys() {
5914 let mut fragments = HashMap::new();
5915 fragments.insert(
5916 "index.html".to_string(),
5917 FragmentList {
5918 fragments: vec![WebUIFragment::component("authored-card")],
5919 contains_boundary: false,
5920 },
5921 );
5922 fragments.insert(
5923 "authored-card".to_string(),
5924 FragmentList {
5925 fragments: vec![WebUIFragment::raw("<p>Card</p>")],
5926 contains_boundary: false,
5927 },
5928 );
5929 let mut protocol = WebUIProtocol::new(fragments);
5930 protocol.components.insert(
5931 "authored-card".to_string(),
5932 webui_protocol::ComponentData {
5933 hydration_mode: webui_protocol::StateProjectionMode::Keys as i32,
5934 hydration_keys: vec!["title".into()],
5935 navigation_mode: Some(webui_protocol::StateProjectionMode::None as i32),
5936 ..Default::default()
5937 },
5938 );
5939
5940 let partial = render_partial(
5941 &protocol,
5942 serde_json::json!({
5943 "serverOnly": "secret",
5944 "title": "Authored",
5945 }),
5946 "index.html",
5947 "/",
5948 "",
5949 )
5950 .unwrap();
5951
5952 assert_eq!(partial["state"], serde_json::json!({}));
5953 }
5954
5955 #[test]
5956 fn prepared_navigation_projects_owned_state_and_reports_matches() {
5957 let mut protocol = WebUIProtocol::new(HashMap::from([
5958 (
5959 "index.html".into(),
5960 FragmentList {
5961 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
5962 path: "/items".into(),
5963 fragment_id: "items-page".into(),
5964 exact: true,
5965 ..Default::default()
5966 })],
5967 ..Default::default()
5968 },
5969 ),
5970 (
5971 "items-page".into(),
5972 FragmentList {
5973 fragments: vec![WebUIFragment::raw("<p>Items</p>")],
5974 ..Default::default()
5975 },
5976 ),
5977 ]));
5978 protocol.components.insert(
5979 "items-page".into(),
5980 webui_protocol::ComponentData {
5981 template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
5982 navigation_mode: Some(StateProjectionMode::Keys as i32),
5983 navigation_keys: vec!["serverDerived".into(), "title".into()],
5984 ..Default::default()
5985 },
5986 );
5987 let protocol = Protocol::new(protocol);
5988 let state = serde_json::json!({
5989 "serverDerived": [1, 2],
5990 "title": "Catalog",
5991 "serverOnly": [3, 4],
5992 "$webui": {"bodyEnd": "<script>host only</script>"}
5993 });
5994 let pointer = state["serverDerived"].as_array().unwrap().as_ptr();
5995 assert!(protocol.matches_route("index.html", "/items"));
5996 assert!(!protocol.matches_route("index.html", "/missing"));
5997 let partial = protocol
5998 .prepare_partial(state, "index.html", "/items", "")
5999 .unwrap();
6000 assert!(partial.is_match());
6001 assert_eq!(
6002 partial.response.state["serverDerived"]
6003 .as_array()
6004 .unwrap()
6005 .as_ptr(),
6006 pointer
6007 );
6008 let wire = serde_json::to_value(partial).unwrap();
6009 assert_eq!(
6010 wire["state"],
6011 serde_json::json!({"serverDerived": [1, 2], "title": "Catalog"})
6012 );
6013 assert_eq!(wire["path"], "/items");
6014 assert_eq!(wire["chain"][0]["component"], "items-page");
6015 assert!(wire.get("matched").is_none());
6016 let missing = protocol
6017 .prepare_partial(Value::Null, "index.html", "/missing", "")
6018 .unwrap();
6019 assert!(!missing.is_match());
6020 }
6021
6022 #[test]
6023 fn prepared_navigation_matches_owned_and_raw_string_responses() {
6024 let protocol = prepared_partial_protocol(&["value"]);
6025 let state = serde_json::json!({
6026 "value": {"label": "片", "count": 42},
6027 "unused": ["discarded"],
6028 "$webui": {"headEnd": "<meta name=\"host-only\">"}
6029 });
6030 let prepared = protocol
6031 .prepare_partial(state.clone(), "index.html", "/", "")
6032 .unwrap();
6033 let bytes = serde_json::to_vec(&prepared).unwrap();
6034 assert_eq!(
6035 bytes,
6036 protocol
6037 .render_partial(state.clone(), "index.html", "/", "")
6038 .unwrap()
6039 .into_bytes()
6040 );
6041 assert_eq!(
6042 bytes,
6043 protocol
6044 .render_partial_json(&state.to_string(), "index.html", "/", "")
6045 .unwrap()
6046 .into_bytes()
6047 );
6048 }
6049
6050 #[test]
6051 fn prepared_navigation_filters_reserved_state_when_projection_is_unknown() {
6052 let protocol = prepared_full_state_partial_protocol();
6053 let response = protocol
6054 .prepare_partial(
6055 serde_json::json!({
6056 "value": 1,
6057 "dynamicField": [2, 3],
6058 "$webui": {"bodyEnd": "<script>host only</script>"}
6059 }),
6060 "index.html",
6061 "/",
6062 "",
6063 )
6064 .unwrap();
6065 let wire = serde_json::to_value(response).unwrap();
6066 assert_eq!(
6067 wire["state"],
6068 serde_json::json!({"value": 1, "dynamicField": [2, 3]})
6069 );
6070 }
6071
6072 #[test]
6073 fn prepared_navigation_keeps_state_for_resident_templates() {
6074 let protocol = prepared_partial_protocol(&["value"]);
6075 let first = protocol
6076 .prepare_partial(Value::Null, "index.html", "/", "")
6077 .unwrap();
6078 let first = serde_json::to_value(first).unwrap();
6079 let response = protocol
6080 .prepare_partial(
6081 serde_json::json!({"value": "updated", "unused": "drop"}),
6082 "index.html",
6083 "/",
6084 first["inventory"].as_str().unwrap(),
6085 )
6086 .unwrap();
6087 let wire = serde_json::to_value(response).unwrap();
6088 assert!(wire["templates"].as_object().unwrap().is_empty());
6089 assert_eq!(wire["state"], serde_json::json!({"value": "updated"}));
6090 }
6091
6092 #[test]
6093 fn fully_static_scriptless_partial_emits_empty_state() {
6094 let mut fragments = HashMap::new();
6095 fragments.insert(
6096 "index.html".to_string(),
6097 FragmentList {
6098 fragments: vec![WebUIFragment::component("static-card")],
6099 contains_boundary: false,
6100 },
6101 );
6102 fragments.insert(
6103 "static-card".to_string(),
6104 FragmentList {
6105 fragments: vec![WebUIFragment::raw("<p>Static</p>")],
6106 contains_boundary: false,
6107 },
6108 );
6109 let mut protocol = WebUIProtocol::new(fragments);
6110 protocol.components.insert(
6111 "static-card".to_string(),
6112 webui_protocol::ComponentData {
6113 template_json: r#"{"h":"<p>Static</p>","th":1}"#.into(),
6114 navigation_mode: Some(webui_protocol::StateProjectionMode::None as i32),
6115 ..Default::default()
6116 },
6117 );
6118
6119 let partial = render_partial(
6120 &protocol,
6121 serde_json::json!({ "serverOnly": "secret" }),
6122 "index.html",
6123 "/",
6124 "",
6125 )
6126 .unwrap();
6127
6128 assert_eq!(partial["state"], serde_json::json!({}));
6129 }
6130
6131 #[test]
6132 fn test_render_component_templates_returns_template_and_css() {
6133 let mut fragments = HashMap::new();
6134 fragments.insert(
6135 "settings-dialog".to_string(),
6136 FragmentList {
6137 fragments: vec![WebUIFragment::raw("<div class='dialog'>Settings</div>")],
6138 contains_boundary: false,
6139 },
6140 );
6141 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
6142 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
6143 let comp = protocol
6144 .components
6145 .entry("settings-dialog".to_string())
6146 .or_default();
6147 comp.template_json = r#"{"h":"<div>Settings</div>"}"#.to_string();
6148 comp.css = ".dialog{position:fixed}".to_string();
6149 protocol.populate_style_closures(&["settings-dialog"]);
6150
6151 let mut index = ProtocolIndex::new(&protocol);
6152 let result =
6153 render_component_templates(&protocol, &["settings-dialog"], "", &mut index).unwrap();
6154 let templates = result["templates"].as_object().expect("templates object");
6155 assert_eq!(templates.len(), 1);
6156 assert_eq!(templates["settings-dialog"]["h"], "<div>Settings</div>");
6157 assert_eq!(
6158 result["componentStyles"]["resources"]["settings-dialog"],
6159 serde_json::json!({
6160 "kind": "module",
6161 "specifier": "settings-dialog",
6162 "css": ".dialog{position:fixed}"
6163 })
6164 );
6165 assert!(
6166 index
6167 .template_metadata_cache
6168 .contains_key("settings-dialog"),
6169 "parsed static template metadata should be cached in ProtocolIndex"
6170 );
6171 }
6172
6173 #[test]
6174 fn test_render_component_templates_respects_inventory() {
6175 let mut fragments = HashMap::new();
6176 fragments.insert(
6177 "my-dialog".to_string(),
6178 FragmentList {
6179 fragments: vec![WebUIFragment::raw("<div>Dialog</div>")],
6180 contains_boundary: false,
6181 },
6182 );
6183 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
6184 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
6185 let comp = protocol
6186 .components
6187 .entry("my-dialog".to_string())
6188 .or_default();
6189 comp.template_json = r#"{"h":"<div>Dialog</div>"}"#.to_string();
6190 comp.css = ".d{color:red}".to_string();
6191 protocol.populate_style_closures(&["my-dialog"]);
6192
6193 let mut index = ProtocolIndex::new(&protocol);
6194 let result1 =
6196 render_component_templates(&protocol, &["my-dialog"], "", &mut index).unwrap();
6197 let inv = result1["inventory"].as_str().expect("inventory string");
6198 assert_eq!(result1["templates"].as_object().unwrap().len(), 1);
6199
6200 let result2 =
6202 render_component_templates(&protocol, &["my-dialog"], inv, &mut index).unwrap();
6203 assert_eq!(result2["templates"].as_object().unwrap().len(), 0);
6204 assert_eq!(
6205 result2["componentStyles"]["closures"],
6206 serde_json::json!({})
6207 );
6208 assert_eq!(
6209 result2["componentStyles"]["resources"],
6210 serde_json::json!({})
6211 );
6212 }
6213
6214 #[test]
6215 fn test_render_component_templates_unknown_component_returns_empty() {
6216 let fragments = HashMap::new();
6217 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
6218
6219 let mut index = ProtocolIndex::new(&protocol);
6220 let result =
6221 render_component_templates(&protocol, &["nonexistent-widget"], "", &mut index).unwrap();
6222 assert_eq!(result["templates"].as_object().unwrap().len(), 0);
6223 assert_eq!(
6224 result["componentStyles"]["resources"],
6225 serde_json::json!({})
6226 );
6227 }
6228
6229 #[test]
6230 fn test_render_partial_includes_sibling_boundary_templates() {
6231 let mut fragments = HashMap::new();
6232 fragments.insert(
6233 "index.html".to_string(),
6234 FragmentList {
6235 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
6236 path: "/".into(),
6237 fragment_id: "app-shell".into(),
6238 exact: false,
6239 children: vec![
6240 WebUiFragmentRoute {
6241 path: "inbox".into(),
6242 fragment_id: "inbox-page".into(),
6243 exact: true,
6244 pending_component: "inbox-loading".into(),
6245 ..Default::default()
6246 },
6247 WebUiFragmentRoute {
6248 path: "settings".into(),
6249 fragment_id: "settings-page".into(),
6250 exact: true,
6251 pending_component: "settings-loading".into(),
6252 error_component: "settings-error".into(),
6253 ..Default::default()
6254 },
6255 ],
6256 ..Default::default()
6257 })],
6258
6259 contains_boundary: false,
6260 },
6261 );
6262
6263 for name in [
6264 "app-shell",
6265 "inbox-page",
6266 "inbox-loading",
6267 "settings-page",
6268 "settings-loading",
6269 "settings-error",
6270 ] {
6271 fragments.insert(
6272 name.to_string(),
6273 FragmentList {
6274 fragments: vec![WebUIFragment::raw(format!("<p>{name}</p>"))],
6275 contains_boundary: false,
6276 },
6277 );
6278 }
6279
6280 let mut protocol = WebUIProtocol::new(fragments);
6281 for name in [
6282 "app-shell",
6283 "inbox-page",
6284 "inbox-loading",
6285 "settings-page",
6286 "settings-loading",
6287 "settings-error",
6288 ] {
6289 protocol
6290 .components
6291 .entry(name.to_string())
6292 .or_default()
6293 .template_json = format!(r#"{{"h":"<p>{name}</p>"}}"#);
6294 }
6295
6296 let mut index = ProtocolIndex::new(&protocol);
6297 let partial =
6298 render_partial_metadata(&protocol, "index.html", "/inbox", "", &mut index).unwrap();
6299 let templates = partial["templates"].as_object().unwrap();
6300
6301 assert!(
6302 templates.contains_key("settings-loading"),
6303 "sibling pending template should be preloaded for route transitions: {partial}"
6304 );
6305 assert!(
6306 templates.contains_key("settings-error"),
6307 "sibling error template should be preloaded for failed route transitions: {partial}"
6308 );
6309 let chain = partial["chain"].as_array().unwrap();
6310 assert_eq!(chain.len(), 2);
6311 assert_eq!(chain[0]["component"], "app-shell");
6312 assert_eq!(chain[1]["component"], "inbox-page");
6313 }
6314
6315 #[test]
6318 fn test_resolve_tag_templates_no_placeholders() {
6319 let tags = vec!["inbox".to_string(), "counts".to_string()];
6320 let params = HashMap::new();
6321 let resolved = resolve_tag_templates(&tags, ¶ms);
6322 assert_eq!(resolved, vec!["inbox", "counts"]);
6323 }
6324
6325 #[test]
6326 fn test_resolve_tag_templates_with_params() {
6327 let tags = vec!["thread:{threadId}".to_string(), "inbox".to_string()];
6328 let mut params = HashMap::new();
6329 params.insert("threadId".to_string(), "42".to_string());
6330 let resolved = resolve_tag_templates(&tags, ¶ms);
6331 assert_eq!(resolved, vec!["thread:42", "inbox"]);
6332 }
6333
6334 #[test]
6335 fn test_resolve_tag_templates_multiple_params() {
6336 let tags = vec!["folder:{folderId}:user:{userId}".to_string()];
6337 let mut params = HashMap::new();
6338 params.insert("folderId".to_string(), "drafts".to_string());
6339 params.insert("userId".to_string(), "abc".to_string());
6340 let resolved = resolve_tag_templates(&tags, ¶ms);
6341 assert_eq!(resolved, vec!["folder:drafts:user:abc"]);
6342 }
6343
6344 #[test]
6345 fn test_resolve_tag_templates_missing_param_left_unresolved() {
6346 let tags = vec!["thread:{threadId}".to_string()];
6347 let params = HashMap::new();
6348 let resolved = resolve_tag_templates(&tags, ¶ms);
6349 assert_eq!(resolved, vec!["thread:{threadId}"]);
6350 }
6351
6352 #[test]
6355 fn test_chain_entry_to_json_includes_new_fields() {
6356 let entry = RouteChainEntry {
6357 component: "mail-thread".into(),
6358 path: "email/:threadId".into(),
6359 params: {
6360 let mut p = HashMap::new();
6361 p.insert("threadId".to_string(), "42".to_string());
6362 p
6363 },
6364 exact: true,
6365 allowed_query: String::new(),
6366 keep_alive: false,
6367 cache_tags: vec!["thread:42".to_string()],
6368 invalidates: vec!["inbox".to_string(), "counts".to_string()],
6369 pending_component: "mail-skeleton".into(),
6370 error_component: "error-page".into(),
6371 };
6372 let json = entry.to_json();
6373 assert_eq!(json["pendingComponent"], "mail-skeleton");
6374 assert_eq!(json["errorComponent"], "error-page");
6375 let inv = json["invalidates"].as_array().unwrap();
6376 assert_eq!(inv.len(), 2);
6377 assert_eq!(inv[0], "inbox");
6378 assert_eq!(inv[1], "counts");
6379 }
6380
6381 #[test]
6382 fn test_chain_entry_to_json_omits_empty_new_fields() {
6383 let entry = RouteChainEntry {
6384 component: "home-page".into(),
6385 path: "/".into(),
6386 params: HashMap::new(),
6387 exact: true,
6388 allowed_query: String::new(),
6389 keep_alive: false,
6390 cache_tags: Vec::new(),
6391 invalidates: Vec::new(),
6392 pending_component: String::new(),
6393 error_component: String::new(),
6394 };
6395 let json = entry.to_json();
6396 assert!(json.get("pendingComponent").is_none());
6397 assert!(json.get("errorComponent").is_none());
6398 assert!(json.get("invalidates").is_none());
6399 }
6400
6401 #[test]
6404 fn test_render_partial_includes_resolved_cache_tags() {
6405 let mut fragments = HashMap::new();
6406 fragments.insert(
6407 "index.html".to_string(),
6408 FragmentList {
6409 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
6410 path: "/".into(),
6411 fragment_id: "app-shell".into(),
6412 exact: false,
6413 children: vec![WebUiFragmentRoute {
6414 path: "email/:threadId".into(),
6415 fragment_id: "mail-thread".into(),
6416 exact: true,
6417 cache_tags: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
6418 ..Default::default()
6419 }],
6420 cache_tags: vec!["folders".to_string()],
6421 ..Default::default()
6422 })],
6423
6424 contains_boundary: false,
6425 },
6426 );
6427 fragments.insert(
6428 "app-shell".to_string(),
6429 FragmentList {
6430 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
6431 contains_boundary: false,
6432 },
6433 );
6434 fragments.insert(
6435 "mail-thread".to_string(),
6436 FragmentList {
6437 fragments: vec![WebUIFragment::raw("<p>Thread</p>")],
6438 contains_boundary: false,
6439 },
6440 );
6441 let protocol = WebUIProtocol::new(fragments);
6442
6443 let mut index = ProtocolIndex::new(&protocol);
6444 let partial =
6445 render_partial_metadata(&protocol, "index.html", "/email/42", "", &mut index).unwrap();
6446 let tags = partial["cacheTags"].as_array().unwrap();
6447 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
6448 assert!(
6449 tag_strings.contains(&"folders"),
6450 "should contain parent tags"
6451 );
6452 assert!(
6453 tag_strings.contains(&"thread:42"),
6454 "should resolve threadId param to 42"
6455 );
6456 assert!(tag_strings.contains(&"inbox"), "should contain static tags");
6457 }
6458
6459 #[test]
6462 fn test_render_action_response_returns_resolved_invalidation_tags() {
6463 let mut fragments = HashMap::new();
6464 fragments.insert(
6465 "index.html".to_string(),
6466 FragmentList {
6467 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
6468 path: "/".into(),
6469 fragment_id: "app-shell".into(),
6470 exact: false,
6471 children: vec![WebUiFragmentRoute {
6472 path: "compose".into(),
6473 fragment_id: "compose-page".into(),
6474 exact: true,
6475 invalidates: vec![
6476 "inbox".to_string(),
6477 "sent".to_string(),
6478 "counts".to_string(),
6479 ],
6480 ..Default::default()
6481 }],
6482 ..Default::default()
6483 })],
6484
6485 contains_boundary: false,
6486 },
6487 );
6488 fragments.insert(
6489 "app-shell".to_string(),
6490 FragmentList {
6491 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
6492 contains_boundary: false,
6493 },
6494 );
6495 fragments.insert(
6496 "compose-page".to_string(),
6497 FragmentList {
6498 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
6499 contains_boundary: false,
6500 },
6501 );
6502 let protocol = WebUIProtocol::new(fragments);
6503
6504 let mut index = ProtocolIndex::new(&protocol);
6505 let result = render_action_response(
6506 &protocol,
6507 serde_json::json!({"ok": true}),
6508 "index.html",
6509 "/compose",
6510 &mut index,
6511 );
6512
6513 let tags = result["invalidateTags"].as_array().unwrap();
6514 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
6515 assert_eq!(tag_strings, vec!["inbox", "sent", "counts"]);
6516 assert_eq!(result["state"]["ok"], true);
6517 }
6518
6519 #[test]
6520 fn test_render_action_response_resolves_param_placeholders() {
6521 let mut fragments = HashMap::new();
6522 fragments.insert(
6523 "index.html".to_string(),
6524 FragmentList {
6525 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
6526 path: "/".into(),
6527 fragment_id: "app-shell".into(),
6528 exact: false,
6529 children: vec![WebUiFragmentRoute {
6530 path: "email/:threadId/reply".into(),
6531 fragment_id: "reply-page".into(),
6532 exact: true,
6533 invalidates: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
6534 ..Default::default()
6535 }],
6536 ..Default::default()
6537 })],
6538
6539 contains_boundary: false,
6540 },
6541 );
6542 fragments.insert(
6543 "app-shell".to_string(),
6544 FragmentList {
6545 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
6546 contains_boundary: false,
6547 },
6548 );
6549 fragments.insert(
6550 "reply-page".to_string(),
6551 FragmentList {
6552 fragments: vec![WebUIFragment::raw("<p>Reply</p>")],
6553 contains_boundary: false,
6554 },
6555 );
6556 let protocol = WebUIProtocol::new(fragments);
6557
6558 let mut index = ProtocolIndex::new(&protocol);
6559 let result = render_action_response(
6560 &protocol,
6561 serde_json::json!({}),
6562 "index.html",
6563 "/email/42/reply",
6564 &mut index,
6565 );
6566
6567 let tags = result["invalidateTags"].as_array().unwrap();
6568 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
6569 assert!(tag_strings.contains(&"thread:42"));
6570 assert!(tag_strings.contains(&"inbox"));
6571 }
6572}