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