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