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