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