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