Skip to main content

webui_handler/
route_handler.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Route component inventory management for incremental route rendering.
5//!
6//! These helpers walk the normal render fragment graph. The request-aware path is
7//! route-aware but state-agnostic: it follows the active route chain for the
8//! current request path, while conservatively traversing `if`, `for`, and
9//! attribute-template edges without evaluating runtime state.
10
11use crate::{route_matcher, route_renderer, HandlerError, StateSelection};
12use route_matcher::CompiledRouteIndex;
13use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor};
14use serde::ser::SerializeMap;
15use serde::Serialize;
16use serde_json::{value::RawValue, Map, Value};
17use std::borrow::Cow;
18use std::collections::{HashMap, HashSet};
19use std::fmt;
20use std::sync::{OnceLock, RwLock};
21use webui_protocol::{web_ui_fragment::Fragment, WebUIFragmentRoute, WebUIProtocol};
22
23// ── Protocol Index ──────────────────────────────────────────────────────
24
25/// A decoded protocol with reusable deterministic indices.
26///
27/// Construct this once when a server loads `protocol.bin`, then reuse it for
28/// full renders, partial navigation, component-template requests, and token
29/// queries. Full renders borrow the immutable protocol without locking.
30/// Authored route patterns are precompiled once and matched as immutable
31/// absolute patterns or relative suffixes. Parsed template metadata is
32/// populated lazily behind a read-write lock whose scope is limited to
33/// individual metadata lookups.
34pub struct Protocol {
35    protocol: WebUIProtocol,
36    component_index: HashMap<String, u32>,
37    component_reachability: OnceLock<ComponentReachabilityIndex>,
38    streaming_plans: HashMap<String, crate::streaming::PreparedStreamingEntryPlan>,
39    route_index: CompiledRouteIndex,
40    legacy_structural_signals: bool,
41    template_metadata_cache: RwLock<HashMap<String, Value>>,
42}
43
44impl Protocol {
45    /// Decode protobuf bytes and build the reusable protocol index.
46    ///
47    /// # Errors
48    ///
49    /// Returns a protocol error when `bytes` is not a valid WebUI protobuf.
50    pub fn from_protobuf(bytes: &[u8]) -> std::result::Result<Self, webui_protocol::ProtocolError> {
51        WebUIProtocol::from_protobuf(bytes).map(Self::new)
52    }
53
54    /// Create a reusable runtime protocol from an already decoded document.
55    #[must_use]
56    pub fn new(protocol: WebUIProtocol) -> Self {
57        let component_index = build_component_index(&protocol);
58        let route_index = CompiledRouteIndex::new(&protocol);
59        let streaming_plans = protocol
60            .streaming_boundaries
61            .keys()
62            .filter_map(|entry| {
63                protocol.fragments.get(entry).map(|fragments| {
64                    (
65                        entry.clone(),
66                        crate::streaming::PreparedStreamingEntryPlan::new(
67                            entry,
68                            &fragments.fragments,
69                            protocol.streaming_boundaries.get(entry),
70                        ),
71                    )
72                })
73            })
74            .collect();
75        let legacy_structural_signals = !protocol.fragments.values().any(|list| {
76            list.fragments.iter().any(|fragment| {
77                matches!(
78                    fragment.fragment.as_ref(),
79                    Some(Fragment::Signal(signal))
80                        if signal.raw
81                            && signal.value.starts_with(crate::STRUCTURAL_SIGNAL_PREFIX)
82                )
83            })
84        });
85        Self {
86            protocol,
87            component_index,
88            component_reachability: OnceLock::new(),
89            streaming_plans,
90            route_index,
91            legacy_structural_signals,
92            template_metadata_cache: RwLock::new(HashMap::new()),
93        }
94    }
95
96    pub(crate) fn protocol(&self) -> &WebUIProtocol {
97        &self.protocol
98    }
99
100    pub(crate) fn component_index(&self) -> &HashMap<String, u32> {
101        &self.component_index
102    }
103
104    pub(crate) fn component_reachability(&self) -> &ComponentReachabilityIndex {
105        self.component_reachability
106            .get_or_init(|| ComponentReachabilityIndex::new(&self.protocol, &self.component_index))
107    }
108
109    pub(crate) fn streaming_plan(
110        &self,
111        entry_id: &str,
112    ) -> crate::Result<Option<&crate::streaming::StreamingEntryPlan>> {
113        self.streaming_plans
114            .get(entry_id)
115            .map_or(Ok(None), |plan| plan.resolve().map(Some))
116    }
117
118    pub(crate) fn streaming_boundary_names(&self, entry_id: &str) -> &[String] {
119        self.protocol
120            .streaming_boundaries
121            .get(entry_id)
122            .map_or(&[], |boundaries| boundaries.names.as_slice())
123    }
124
125    pub(crate) fn route_index(&self) -> &CompiledRouteIndex {
126        &self.route_index
127    }
128
129    pub(crate) fn legacy_structural_signals(&self) -> bool {
130        self.legacy_structural_signals
131    }
132
133    /// Borrow the build-time CSS token list.
134    #[must_use]
135    pub fn tokens(&self) -> &[String] {
136        &self.protocol.tokens
137    }
138
139    /// Produce a complete partial-navigation response.
140    pub fn render_partial(
141        &self,
142        state_json: &str,
143        entry_id: &str,
144        request_path: &str,
145        inventory_hex: &str,
146    ) -> Result<String, HandlerError> {
147        let mut index = self.request_index();
148        let (response, state_selection) = render_partial_indexed_with_state(
149            self.protocol(),
150            entry_id,
151            request_path,
152            inventory_hex,
153            &mut index,
154        )?;
155        serialize_partial_response(&response, state_json, &state_selection)
156    }
157
158    /// Render component template payloads for requested component tags.
159    pub fn render_component_templates(
160        &self,
161        component_tags: &[&str],
162        inventory_hex: &str,
163    ) -> Result<Value, HandlerError> {
164        let mut index = self.request_index();
165        render_component_templates_indexed(
166            self.protocol(),
167            component_tags,
168            inventory_hex,
169            &mut index,
170        )
171    }
172
173    #[cfg(test)]
174    fn render_partial_metadata(
175        &self,
176        entry_id: &str,
177        request_path: &str,
178        inventory_hex: &str,
179    ) -> Result<Value, HandlerError> {
180        let mut index = self.request_index();
181        render_partial_indexed(
182            self.protocol(),
183            entry_id,
184            request_path,
185            inventory_hex,
186            &mut index,
187        )
188    }
189
190    fn request_index(&self) -> RequestProtocolIndex<'_> {
191        RequestProtocolIndex {
192            component_index: &self.component_index,
193            route_index: &self.route_index,
194            template_metadata_cache: TemplateMetadataCache::Shared(&self.template_metadata_cache),
195        }
196    }
197}
198
199/// Pre-computed index for a protocol, caching expensive lookups.
200///
201/// The component bit-index map is deterministic for a given protocol. The
202/// route index and template metadata cache support test callers that create and
203/// reuse a standalone index directly.
204#[cfg(test)]
205struct ProtocolIndex {
206    /// Component name → bit index for inventory tracking.
207    pub component_index: HashMap<String, u32>,
208    /// Authored route template patterns compiled once.
209    pub route_index: CompiledRouteIndex,
210    /// Parsed WebUI template metadata keyed by component tag.
211    template_metadata_cache: HashMap<String, Value>,
212}
213
214struct ComponentAssets {
215    styles: Vec<Value>,
216    templates: serde_json::Map<String, Value>,
217    functions: serde_json::Map<String, Value>,
218}
219
220#[cfg(test)]
221impl ProtocolIndex {
222    /// Build a protocol index from a compiled protocol.
223    #[must_use]
224    pub(crate) fn new(protocol: &WebUIProtocol) -> Self {
225        Self {
226            component_index: build_component_index(protocol),
227            route_index: CompiledRouteIndex::new(protocol),
228            template_metadata_cache: HashMap::new(),
229        }
230    }
231
232    fn request_index(&mut self) -> RequestProtocolIndex<'_> {
233        RequestProtocolIndex {
234            component_index: &self.component_index,
235            route_index: &self.route_index,
236            template_metadata_cache: TemplateMetadataCache::Exclusive(
237                &mut self.template_metadata_cache,
238            ),
239        }
240    }
241}
242
243enum TemplateMetadataCache<'a> {
244    #[cfg(test)]
245    Exclusive(&'a mut HashMap<String, Value>),
246    Shared(&'a RwLock<HashMap<String, Value>>),
247}
248
249struct RequestProtocolIndex<'a> {
250    component_index: &'a HashMap<String, u32>,
251    route_index: &'a CompiledRouteIndex,
252    template_metadata_cache: TemplateMetadataCache<'a>,
253}
254
255// ── Component Inventory ─────────────────────────────────────────────────
256
257/// Build a deterministic component-name → bit-index map from the protocol.
258///
259/// Derives names from fragment keys (hyphenated = custom element) since that
260/// is the source of truth regardless of whether a plugin populated
261/// `protocol.components`. Components are sorted alphabetically; index =
262/// position in that order.
263pub(crate) fn build_component_index(protocol: &WebUIProtocol) -> HashMap<String, u32> {
264    let mut sorted: Vec<&String> = protocol
265        .fragments
266        .keys()
267        .filter(|key| key.contains('-'))
268        .collect();
269    sorted.sort_unstable();
270    let mut index = HashMap::with_capacity(sorted.len());
271    for (i, name) in sorted.into_iter().enumerate() {
272        // Index count bounded by component registry size, well within u32 range
273        #[allow(clippy::cast_possible_truncation)]
274        let idx = i as u32;
275        index.insert(name.clone(), idx);
276    }
277    index
278}
279
280/// Startup-built direct component dependency graph used by streaming
281/// checkpoints. Route-free component surfaces walk integer indexes on the
282/// request path; only route-dependent surfaces need the more expensive
283/// request-aware fragment traversal.
284pub(crate) struct ComponentReachabilityIndex {
285    names: Vec<String>,
286    dependencies: Vec<Box<[u32]>>,
287    route_dependent: Vec<bool>,
288}
289
290impl ComponentReachabilityIndex {
291    fn new(protocol: &WebUIProtocol, component_index: &HashMap<String, u32>) -> Self {
292        let mut names: Vec<String> = component_index.keys().cloned().collect();
293        names.sort_unstable();
294
295        let mut dependencies = Vec::with_capacity(names.len());
296        let mut route_dependent = Vec::with_capacity(names.len());
297        for name in &names {
298            let (direct, has_route) =
299                collect_direct_component_dependencies(protocol, name, component_index);
300            dependencies.push(direct.into_boxed_slice());
301            route_dependent.push(has_route);
302        }
303        propagate_route_dependencies(&dependencies, &mut route_dependent);
304
305        Self {
306            names,
307            dependencies,
308            route_dependent,
309        }
310    }
311
312    pub(crate) fn name(&self, index: u32) -> Option<&str> {
313        self.names.get(index as usize).map(String::as_str)
314    }
315
316    pub(crate) fn dependencies(&self, index: u32) -> Option<&[u32]> {
317        self.dependencies.get(index as usize).map(Box::as_ref)
318    }
319
320    pub(crate) fn is_route_dependent(&self, index: u32) -> Option<bool> {
321        self.route_dependent.get(index as usize).copied()
322    }
323
324    pub(crate) fn requires_expansion(&self, index: u32) -> Option<bool> {
325        Some(self.is_route_dependent(index)? || !self.dependencies.get(index as usize)?.is_empty())
326    }
327}
328
329enum ComponentDependencyWork<'a> {
330    Fragment(&'a str),
331    Component(u32),
332}
333
334fn collect_direct_component_dependencies(
335    protocol: &WebUIProtocol,
336    root: &str,
337    component_index: &HashMap<String, u32>,
338) -> (Vec<u32>, bool) {
339    let mut work = vec![ComponentDependencyWork::Fragment(root)];
340    let mut visited_fragments = HashSet::new();
341    let mut seen_components = HashSet::new();
342    let mut dependencies = Vec::new();
343    let mut has_route = false;
344
345    while let Some(item) = work.pop() {
346        match item {
347            ComponentDependencyWork::Component(index) => {
348                if seen_components.insert(index) {
349                    dependencies.push(index);
350                }
351            }
352            ComponentDependencyWork::Fragment(id) => {
353                if !visited_fragments.insert(id) {
354                    continue;
355                }
356                let Some(list) = protocol.fragments.get(id) else {
357                    continue;
358                };
359                for fragment in list.fragments.iter().rev() {
360                    match fragment.fragment.as_ref() {
361                        Some(Fragment::Component(component)) => {
362                            if let Some(&index) = component_index.get(&component.fragment_id) {
363                                work.push(ComponentDependencyWork::Component(index));
364                            }
365                        }
366                        Some(Fragment::ForLoop(for_loop)) => work.push(
367                            ComponentDependencyWork::Fragment(for_loop.fragment_id.as_str()),
368                        ),
369                        Some(Fragment::IfCond(if_cond)) => work.push(
370                            ComponentDependencyWork::Fragment(if_cond.fragment_id.as_str()),
371                        ),
372                        Some(Fragment::Attribute(attribute)) if !attribute.template.is_empty() => {
373                            work.push(ComponentDependencyWork::Fragment(
374                                attribute.template.as_str(),
375                            ));
376                        }
377                        Some(Fragment::Route(_)) => has_route = true,
378                        _ => {}
379                    }
380                }
381            }
382        }
383    }
384
385    (dependencies, has_route)
386}
387
388fn propagate_route_dependencies(dependencies: &[Box<[u32]>], route_dependent: &mut [bool]) {
389    let mut reverse = vec![Vec::new(); dependencies.len()];
390    for (parent, children) in dependencies.iter().enumerate() {
391        for &child in children.iter() {
392            if let Some(parents) = reverse.get_mut(child as usize) {
393                #[allow(clippy::cast_possible_truncation)]
394                parents.push(parent as u32);
395            }
396        }
397    }
398
399    let mut pending = Vec::new();
400    for (index, &depends_on_route) in route_dependent.iter().enumerate() {
401        if depends_on_route {
402            #[allow(clippy::cast_possible_truncation)]
403            pending.push(index as u32);
404        }
405    }
406    while let Some(index) = pending.pop() {
407        let Some(parents) = reverse.get(index as usize) else {
408            continue;
409        };
410        for &parent in parents {
411            let Some(depends_on_route) = route_dependent.get_mut(parent as usize) else {
412                continue;
413            };
414            if !*depends_on_route {
415                *depends_on_route = true;
416                pending.push(parent);
417            }
418        }
419    }
420}
421
422/// Check if a component's bit is set in the inventory bitfield.
423fn has_component(inventory: &[u8], index: u32) -> bool {
424    let byte_idx = (index / 8) as usize;
425    let bit_idx = index % 8;
426    byte_idx < inventory.len() && inventory[byte_idx] & (1 << bit_idx) != 0
427}
428
429/// Set a component's bit in the inventory bitfield.
430fn set_component(inventory: &mut Vec<u8>, index: u32) {
431    let byte_idx = (index / 8) as usize;
432    let bit_idx = index % 8;
433    if byte_idx >= inventory.len() {
434        inventory.resize(byte_idx + 1, 0);
435    }
436    inventory[byte_idx] |= 1 << bit_idx;
437}
438
439/// Parse a hex-encoded inventory string into bytes.
440///
441/// Returns an error if the hex string has odd length or contains non-hex characters.
442pub fn parse_inventory(hex: &str) -> Result<Vec<u8>, HandlerError> {
443    if hex.is_empty() {
444        return Ok(Vec::new());
445    }
446    if !hex.len().is_multiple_of(2) {
447        return Err(HandlerError::Protocol(
448            webui_protocol::ProtocolError::Validation(format!(
449                "inventory hex has odd length: {}",
450                hex.len()
451            )),
452        ));
453    }
454    let mut bytes = Vec::with_capacity(hex.len() / 2);
455    let mut chars = hex.bytes();
456    while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
457        let h = route_matcher::hex_val(hi).ok_or_else(|| {
458            HandlerError::Protocol(webui_protocol::ProtocolError::Validation(format!(
459                "invalid hex digit in inventory: {:#04x}",
460                hi
461            )))
462        })?;
463        let l = route_matcher::hex_val(lo).ok_or_else(|| {
464            HandlerError::Protocol(webui_protocol::ProtocolError::Validation(format!(
465                "invalid hex digit in inventory: {:#04x}",
466                lo
467            )))
468        })?;
469        bytes.push((h << 4) | l);
470    }
471    Ok(bytes)
472}
473
474/// Encode an inventory bitfield as a hex string.
475pub fn encode_inventory(inv: &[u8]) -> String {
476    const HEX: &[u8; 16] = b"0123456789abcdef";
477    let mut encoded = String::with_capacity(inv.len() * 2);
478    for byte in inv {
479        encoded.push(char::from(HEX[usize::from(byte >> 4)]));
480        encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
481    }
482    encoded
483}
484
485/// Encode the inventory bitfield for a known set of rendered components.
486pub(crate) fn encode_component_inventory(
487    component_names: &HashSet<String>,
488    index: &HashMap<String, u32>,
489) -> String {
490    let mut inventory = Vec::new();
491    for name in component_names {
492        if let Some(&idx) = index.get(name.as_str()) {
493            set_component(&mut inventory, idx);
494        }
495    }
496    encode_inventory(&inventory)
497}
498
499/// Walk the protocol fragment graph from `entry_id` and return the names of
500/// all components the route needs that are NOT in the client's inventory.
501///
502/// Returns `(needed_names, updated_inventory_hex)`.
503pub fn get_needed_components(
504    protocol: &WebUIProtocol,
505    entry_id: &str,
506    inventory_hex: &str,
507    component_index: &HashMap<String, u32>,
508) -> Result<(Vec<String>, String), HandlerError> {
509    let route_index = CompiledRouteIndex::new(protocol);
510    let component_names =
511        collect_inventoryable_components(protocol, entry_id, None, true, &route_index);
512    filter_needed_components(&component_names, inventory_hex, component_index)
513}
514
515/// Walk the protocol from the persistent `entry_id` and return the component
516/// templates needed for the current `request_path`.
517///
518/// Returns `(needed_names, updated_inventory_hex)`.
519pub(crate) fn get_needed_components_for_request(
520    protocol: &WebUIProtocol,
521    entry_id: &str,
522    request_path: &str,
523    inventory_hex: &str,
524    indexes: (&HashMap<String, u32>, &CompiledRouteIndex),
525) -> Result<(Vec<String>, String), HandlerError> {
526    let (component_index, route_index) = indexes;
527    let component_names = collect_inventoryable_components(
528        protocol,
529        entry_id,
530        Some(request_path),
531        false,
532        route_index,
533    );
534    filter_needed_components(&component_names, inventory_hex, component_index)
535}
536
537fn serialize_partial_response(
538    response: &Value,
539    state_json: &str,
540    state_selection: &StateSelection<'_>,
541) -> Result<String, HandlerError> {
542    let response = response
543        .as_object()
544        .ok_or_else(partial_response_not_object)?;
545    let state = select_raw_state(state_json, state_selection)?;
546    serde_json::to_string(&PartialResponseWithState { response, state })
547        .map_err(|error| partial_serialize_error(&error.to_string()))
548}
549
550fn validate_json(json: &str) -> Result<(), HandlerError> {
551    validate_json_inner(json).map_err(|error| invalid_state_json(&error.to_string()))
552}
553
554fn validate_json_inner(json: &str) -> serde_json::Result<()> {
555    let mut deserializer = serde_json::Deserializer::from_str(json);
556    ValidJson
557        .deserialize(&mut deserializer)
558        .and_then(|_| deserializer.end())
559}
560
561struct ValidJson;
562
563impl<'de> DeserializeSeed<'de> for ValidJson {
564    type Value = ();
565
566    fn deserialize<D>(self, deserializer: D) -> std::result::Result<(), D::Error>
567    where
568        D: serde::Deserializer<'de>,
569    {
570        deserializer.deserialize_any(ValidJsonVisitor)
571    }
572}
573
574struct ValidJsonVisitor;
575
576impl<'de> Visitor<'de> for ValidJsonVisitor {
577    type Value = ();
578
579    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
580        formatter.write_str("valid JSON")
581    }
582
583    fn visit_bool<E>(self, _value: bool) -> std::result::Result<(), E> {
584        Ok(())
585    }
586
587    fn visit_i64<E>(self, _value: i64) -> std::result::Result<(), E> {
588        Ok(())
589    }
590
591    fn visit_u64<E>(self, _value: u64) -> std::result::Result<(), E> {
592        Ok(())
593    }
594
595    fn visit_f64<E>(self, value: f64) -> std::result::Result<(), E>
596    where
597        E: serde::de::Error,
598    {
599        if value.is_finite() {
600            Ok(())
601        } else {
602            Err(E::custom("number out of range"))
603        }
604    }
605
606    fn visit_str<E>(self, _value: &str) -> std::result::Result<(), E> {
607        Ok(())
608    }
609
610    fn visit_borrowed_str<E>(self, _value: &'de str) -> std::result::Result<(), E> {
611        Ok(())
612    }
613
614    fn visit_string<E>(self, _value: String) -> std::result::Result<(), E> {
615        Ok(())
616    }
617
618    fn visit_unit<E>(self) -> std::result::Result<(), E> {
619        Ok(())
620    }
621
622    fn visit_none<E>(self) -> std::result::Result<(), E> {
623        Ok(())
624    }
625
626    fn visit_some<D>(self, deserializer: D) -> std::result::Result<(), D::Error>
627    where
628        D: serde::Deserializer<'de>,
629    {
630        ValidJson.deserialize(deserializer)
631    }
632
633    fn visit_seq<A>(self, mut sequence: A) -> std::result::Result<(), A::Error>
634    where
635        A: SeqAccess<'de>,
636    {
637        while sequence.next_element_seed(ValidJson)?.is_some() {}
638        Ok(())
639    }
640
641    fn visit_map<A>(self, mut map: A) -> std::result::Result<(), A::Error>
642    where
643        A: MapAccess<'de>,
644    {
645        while map.next_key::<IgnoredAny>()?.is_some() {
646            map.next_value_seed(ValidJson)?;
647        }
648        Ok(())
649    }
650}
651
652struct PartialResponseWithState<'a, 'state> {
653    response: &'a Map<String, Value>,
654    state: SelectedRawState<'state>,
655}
656
657impl Serialize for PartialResponseWithState<'_, '_> {
658    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
659    where
660        S: serde::Serializer,
661    {
662        let mut map = serializer.serialize_map(Some(self.response.len().saturating_add(1)))?;
663        for (key, value) in self.response {
664            map.serialize_entry(key, value)?;
665        }
666        map.serialize_entry("state", &self.state)?;
667        map.end()
668    }
669}
670
671struct ProjectedRawState<'de> {
672    entries: Vec<(Cow<'de, str>, &'de RawValue)>,
673}
674
675enum SelectedRawState<'de> {
676    Full(&'de RawValue),
677    Keys(ProjectedRawState<'de>),
678}
679
680impl Serialize for SelectedRawState<'_> {
681    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
682    where
683        S: serde::Serializer,
684    {
685        match self {
686            Self::Full(state) => state.serialize(serializer),
687            Self::Keys(state) => state.serialize(serializer),
688        }
689    }
690}
691
692impl Serialize for ProjectedRawState<'_> {
693    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
694    where
695        S: serde::Serializer,
696    {
697        let mut map = serializer.serialize_map(Some(self.entries.len()))?;
698        for (key, value) in &self.entries {
699            map.serialize_entry(key.as_ref(), value)?;
700        }
701        map.end()
702    }
703}
704
705fn project_raw_state<'de>(
706    state_json: &'de str,
707    state_keys: &[&str],
708) -> Result<ProjectedRawState<'de>, HandlerError> {
709    let is_object = state_json
710        .as_bytes()
711        .iter()
712        .find(|byte| !byte.is_ascii_whitespace())
713        .is_some_and(|byte| *byte == b'{');
714    if !is_object {
715        validate_json(state_json)?;
716        return Ok(ProjectedRawState {
717            entries: Vec::new(),
718        });
719    }
720
721    let mut deserializer = serde_json::Deserializer::from_str(state_json);
722    let mut state = ProjectedRawStateSeed { state_keys }
723        .deserialize(&mut deserializer)
724        .and_then(|state| {
725            deserializer.end()?;
726            Ok(state)
727        })
728        .map_err(|error| invalid_state_json(&error.to_string()))?;
729    state.entries.sort_unstable_by(|left, right| {
730        left.0.as_ref().as_bytes().cmp(right.0.as_ref().as_bytes())
731    });
732    Ok(state)
733}
734
735fn select_raw_state<'de>(
736    state_json: &'de str,
737    selection: &StateSelection<'_>,
738) -> Result<SelectedRawState<'de>, HandlerError> {
739    let state_keys = match selection {
740        StateSelection::Full => {
741            return serde_json::from_str::<&RawValue>(state_json)
742                .map(SelectedRawState::Full)
743                .map_err(|error| invalid_state_json(&error.to_string()));
744        }
745        StateSelection::Keys(keys) => keys.as_slice(),
746        StateSelection::BorrowedKeys(keys) => *keys,
747    };
748    project_raw_state(state_json, state_keys).map(SelectedRawState::Keys)
749}
750
751struct ProjectedRawStateSeed<'a> {
752    state_keys: &'a [&'a str],
753}
754
755impl<'de> DeserializeSeed<'de> for ProjectedRawStateSeed<'_> {
756    type Value = ProjectedRawState<'de>;
757
758    fn deserialize<D>(self, deserializer: D) -> std::result::Result<Self::Value, D::Error>
759    where
760        D: serde::Deserializer<'de>,
761    {
762        deserializer.deserialize_map(ProjectedRawStateVisitor {
763            state_keys: self.state_keys,
764        })
765    }
766}
767
768struct ProjectedRawStateVisitor<'a> {
769    state_keys: &'a [&'a str],
770}
771
772impl<'de> Visitor<'de> for ProjectedRawStateVisitor<'_> {
773    type Value = ProjectedRawState<'de>;
774
775    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
776        formatter.write_str("a JSON object")
777    }
778
779    fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
780    where
781        A: MapAccess<'de>,
782    {
783        let mut entries: Vec<(Cow<'de, str>, &'de RawValue)> =
784            Vec::with_capacity(self.state_keys.len());
785        while let Some(key) = map.next_key_seed(BorrowedString)? {
786            if self.state_keys.binary_search(&key.as_ref()).is_err() {
787                map.next_value_seed(ValidJson)?;
788                continue;
789            }
790
791            let value: &'de RawValue = map.next_value()?;
792            validate_json_inner(value.get()).map_err(serde::de::Error::custom)?;
793            if let Some(entry) = entries
794                .iter_mut()
795                .find(|(existing, _)| existing.as_ref() == key.as_ref())
796            {
797                *entry = (key, value);
798            } else {
799                entries.push((key, value));
800            }
801        }
802        Ok(ProjectedRawState { entries })
803    }
804}
805
806struct BorrowedString;
807
808impl<'de> DeserializeSeed<'de> for BorrowedString {
809    type Value = Cow<'de, str>;
810
811    fn deserialize<D>(self, deserializer: D) -> std::result::Result<Self::Value, D::Error>
812    where
813        D: serde::Deserializer<'de>,
814    {
815        deserializer.deserialize_str(BorrowedStringVisitor)
816    }
817}
818
819struct BorrowedStringVisitor;
820
821impl<'de> Visitor<'de> for BorrowedStringVisitor {
822    type Value = Cow<'de, str>;
823
824    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
825        formatter.write_str("a string")
826    }
827
828    fn visit_borrowed_str<E>(self, value: &'de str) -> std::result::Result<Self::Value, E> {
829        Ok(Cow::Borrowed(value))
830    }
831
832    fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E> {
833        Ok(Cow::Owned(value.to_owned()))
834    }
835
836    fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E> {
837        Ok(Cow::Owned(value))
838    }
839}
840
841#[cold]
842#[inline(never)]
843fn invalid_state_json(message: &str) -> HandlerError {
844    HandlerError::Rendering(format!("invalid state JSON: {message}"))
845}
846
847#[cold]
848#[inline(never)]
849fn partial_serialize_error(message: &str) -> HandlerError {
850    HandlerError::Rendering(format!("failed to serialize partial response: {message}"))
851}
852
853#[cold]
854#[inline(never)]
855fn partial_response_not_object() -> HandlerError {
856    HandlerError::Invariant("partial response must serialize as a JSON object".to_string())
857}
858
859/// Collect all route-reachable inventoryable components for the request path.
860pub(crate) fn collect_reachable_components_for_request(
861    protocol: &WebUIProtocol,
862    entry_id: &str,
863    request_path: &str,
864    route_index: &CompiledRouteIndex,
865) -> HashSet<String> {
866    // Callers here need set semantics (`.contains`, plugin `&HashSet` API);
867    // discovery order is irrelevant for reachable-template emission.
868    collect_inventoryable_components(protocol, entry_id, Some(request_path), false, route_index)
869        .into_iter()
870        .collect()
871}
872
873/// Filter components against the client's inventory bitfield using sequential indices.
874/// Zero collisions — each component has a unique bit.
875///
876/// Expects `component_names` to already be unique. Input order is preserved
877/// in the returned `needed` vector (no alphabetical re-sort), so downstream
878/// `<head>` CSS `<link>` emission follows document/traversal order. The
879/// updated inventory hex is order-independent (bits are keyed by `index`).
880///
881/// Returns the missing component names and the updated inventory hex string.
882pub fn filter_needed_components(
883    component_names: &[String],
884    inventory_hex: &str,
885    index: &HashMap<String, u32>,
886) -> Result<(Vec<String>, String), HandlerError> {
887    let client_inv = parse_inventory(inventory_hex)?;
888    let mut updated_inv = client_inv.clone();
889
890    let mut needed = Vec::with_capacity(component_names.len());
891    for name in component_names {
892        if let Some(&idx) = index.get(name.as_str()) {
893            if !has_component(&client_inv, idx) {
894                needed.push(name.clone());
895            }
896            set_component(&mut updated_inv, idx);
897        } else {
898            // Component exists in the fragment graph but has no index entry
899            // (no protocol.components record). It can't be tracked in the
900            // bitfield, so we must always send it.
901            needed.push(name.clone());
902        }
903    }
904
905    Ok((needed, encode_inventory(&updated_inv)))
906}
907
908fn has_template_payload(component: &webui_protocol::ComponentData) -> bool {
909    !component.template_json.is_empty() || !component.template.is_empty()
910}
911
912#[derive(Debug)]
913struct QueuedFragment {
914    id: String,
915    inventoryable: bool,
916    /// Base path for resolving relative route paths at this level.
917    route_base: String,
918}
919
920#[inline]
921fn mark_fragment_visited(
922    visited: &mut HashSet<(String, String)>,
923    queued: &QueuedFragment,
924    route_sensitive: bool,
925) -> bool {
926    let route_base = if route_sensitive {
927        queued.route_base.clone()
928    } else {
929        String::new()
930    };
931    visited.insert((queued.id.clone(), route_base))
932}
933
934/// Shared context for route child walkers, bundling common parameters
935/// to stay within the 5-argument clippy limit.
936struct ChildWalkCtx<'a> {
937    request_path: &'a str,
938    protocol: &'a WebUIProtocol,
939    route_index: &'a CompiledRouteIndex,
940}
941
942/// Walk the fragment graph from `entry_id` and collect all inventoryable
943/// component names in document/traversal (first-discovery) order.
944///
945/// Uses an iterative stack-based traversal. When `request_path` is provided,
946/// the walk follows only the best-matching route at each nesting level (route-aware).
947/// Without a request path, all route branches are followed conservatively.
948///
949/// Components are marked `inventoryable` when they have a corresponding entry
950/// in `protocol.components` with a non-empty template payload — these are the
951/// components whose client metadata the browser may need during navigation.
952fn collect_inventoryable_components(
953    protocol: &WebUIProtocol,
954    entry_id: &str,
955    request_path: Option<&str>,
956    root_inventoryable: bool,
957    route_index: &CompiledRouteIndex,
958) -> Vec<String> {
959    let stack = vec![QueuedFragment {
960        id: entry_id.to_string(),
961        inventoryable: root_inventoryable,
962        route_base: "/".to_string(),
963    }];
964    collect_inventoryable_components_from_stack(protocol, request_path, route_index, stack)
965}
966
967/// Collect the transitive client component surface rooted in this checkpoint's
968/// rendered tags. Conditional and empty-repeat branches are followed
969/// conservatively so a later client-side state change already has its metadata.
970///
971/// Roots arrive as startup-built component indexes so the caller's capture stays
972/// free of any protocol borrow; names are resolved here, on the uncommon
973/// request-aware path that already converts every root to an owned string.
974pub(crate) fn collect_reachable_components_from_roots(
975    protocol: &WebUIProtocol,
976    roots: &[(u32, Option<Box<str>>)],
977    reachability: &ComponentReachabilityIndex,
978    request_path: &str,
979    route_index: &CompiledRouteIndex,
980) -> Vec<String> {
981    let mut stack = Vec::with_capacity(roots.len());
982    for (root, route_base) in roots.iter().rev() {
983        let Some(name) = reachability.name(*root) else {
984            continue;
985        };
986        stack.push(QueuedFragment {
987            id: name.to_string(),
988            inventoryable: true,
989            route_base: route_base.as_deref().unwrap_or("/").to_string(),
990        });
991    }
992    collect_inventoryable_components_from_stack(protocol, Some(request_path), route_index, stack)
993}
994
995fn collect_inventoryable_components_from_stack(
996    protocol: &WebUIProtocol,
997    request_path: Option<&str>,
998    route_index: &CompiledRouteIndex,
999    mut stack: Vec<QueuedFragment>,
1000) -> Vec<String> {
1001    let mut visited_fragments = HashSet::new();
1002    let route_sensitive = request_path.is_some();
1003    // Preserve first-discovery (document/traversal) order so Link-strategy
1004    // CSS `<link>` tags are emitted in source order, not alphabetically.
1005    // `seen_components` dedups; `component_ids` keeps order (a plain
1006    // `HashSet` would lose it).
1007    let mut seen_components = HashSet::new();
1008    let mut component_ids: Vec<String> = Vec::new();
1009    while let Some(queued) = stack.pop() {
1010        if queued.id.is_empty() {
1011            continue;
1012        }
1013
1014        if queued.inventoryable && seen_components.insert(queued.id.clone()) {
1015            component_ids.push(queued.id.clone());
1016        }
1017
1018        if !mark_fragment_visited(&mut visited_fragments, &queued, route_sensitive) {
1019            continue;
1020        }
1021
1022        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1023            continue;
1024        };
1025
1026        let matched_route = request_path.and_then(|path| {
1027            route_renderer::find_best_route_match(
1028                &frag_list.fragments,
1029                path,
1030                &queued.route_base,
1031                route_index,
1032            )
1033        });
1034
1035        // Reversed iteration so this LIFO stack pops children in document
1036        // order, giving deterministic, source-ordered component discovery.
1037        for frag in frag_list.fragments.iter().rev() {
1038            match frag.fragment.as_ref() {
1039                Some(Fragment::Component(component)) => {
1040                    stack.push(QueuedFragment {
1041                        id: component.fragment_id.clone(),
1042                        inventoryable: true,
1043                        route_base: queued.route_base.clone(),
1044                    });
1045                }
1046                Some(Fragment::ForLoop(for_loop)) => {
1047                    stack.push(QueuedFragment {
1048                        id: for_loop.fragment_id.clone(),
1049                        inventoryable: false,
1050                        route_base: queued.route_base.clone(),
1051                    });
1052                }
1053                Some(Fragment::IfCond(if_cond)) => {
1054                    stack.push(QueuedFragment {
1055                        id: if_cond.fragment_id.clone(),
1056                        inventoryable: false,
1057                        route_base: queued.route_base.clone(),
1058                    });
1059                }
1060                Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
1061                    stack.push(QueuedFragment {
1062                        id: attr.template.clone(),
1063                        inventoryable: false,
1064                        route_base: queued.route_base.clone(),
1065                    });
1066                }
1067                Some(Fragment::Route(route_frag)) => {
1068                    let is_selected = matched_route
1069                        .as_ref()
1070                        .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1071                    if is_selected && !route_frag.fragment_id.is_empty() {
1072                        // Compute new route base from consumed segments
1073                        let child_route_base = if let Some((_, ref rm)) = matched_route {
1074                            if let Some(path) = request_path {
1075                                route_matcher::compute_route_base(path, rm.consumed_segments)
1076                            } else {
1077                                queued.route_base.clone()
1078                            }
1079                        } else {
1080                            queued.route_base.clone()
1081                        };
1082
1083                        stack.push(QueuedFragment {
1084                            id: route_frag.fragment_id.clone(),
1085                            inventoryable: protocol
1086                                .components
1087                                .get(&route_frag.fragment_id)
1088                                .is_some_and(has_template_payload),
1089                            route_base: child_route_base.clone(),
1090                        });
1091
1092                        // Inventory pending/error components for the entire
1093                        // route subtree, not just the matched chain. Pending UI
1094                        // renders while a (possibly unmatched) sibling route's
1095                        // partial is in flight, and error UI renders if that
1096                        // fetch fails, so the client must already hold these
1097                        // templates before — or without — a successful fetch for
1098                        // the target route.
1099                        collect_route_boundary_components(
1100                            std::slice::from_ref(route_frag),
1101                            &child_route_base,
1102                            protocol,
1103                            &mut stack,
1104                        );
1105
1106                        // Walk nested child routes to find the next matched level.
1107                        // This mirrors the handler's outlet rendering: match children
1108                        // against the request path and follow the matched chain.
1109                        if !route_frag.children.is_empty() {
1110                            if let Some(path) = request_path {
1111                                walk_route_children(
1112                                    &route_frag.children,
1113                                    &child_route_base,
1114                                    &mut stack,
1115                                    &mut ChildWalkCtx {
1116                                        request_path: path,
1117                                        protocol,
1118                                        route_index,
1119                                    },
1120                                );
1121                            }
1122                        }
1123                    }
1124                }
1125                _ => {}
1126            }
1127        }
1128    }
1129
1130    component_ids
1131}
1132
1133/// Select the best-matching child route among siblings by specificity.
1134///
1135/// Returns the index and match result of the highest-specificity match,
1136/// preserving first-match-wins for equal specificity (declaration order).
1137fn select_best_child_route(
1138    children: &[WebUIFragmentRoute],
1139    request_path: &str,
1140    route_base: &str,
1141    route_index: &CompiledRouteIndex,
1142) -> Option<(usize, route_matcher::RouteMatch)> {
1143    let request_segments = route_matcher::split_request_path(request_path);
1144    let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
1145    for (idx, child) in children.iter().enumerate() {
1146        if let Some(m) = route_matcher::match_route_indexed_with_segments(
1147            route_index,
1148            &child.path,
1149            route_base,
1150            &request_segments,
1151            child.exact,
1152        ) {
1153            let is_better = best
1154                .as_ref()
1155                .is_none_or(|(_, prev)| m.specificity > prev.specificity);
1156            if is_better {
1157                best = Some((idx, m));
1158            }
1159        }
1160    }
1161    best
1162}
1163
1164/// Walk nested route children to find matched routes and add their
1165/// components to the inventory stack. Mirrors the handler's outlet rendering.
1166fn walk_route_children(
1167    children: &[WebUIFragmentRoute],
1168    route_base: &str,
1169    stack: &mut Vec<QueuedFragment>,
1170    ctx: &mut ChildWalkCtx<'_>,
1171) {
1172    let mut current = children;
1173    let mut base = route_base.to_string();
1174
1175    loop {
1176        let Some((idx, ref rm)) =
1177            select_best_child_route(current, ctx.request_path, &base, ctx.route_index)
1178        else {
1179            break;
1180        };
1181        let matched = &current[idx];
1182        if matched.fragment_id.is_empty() {
1183            break;
1184        }
1185
1186        let child_base = route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
1187
1188        stack.push(QueuedFragment {
1189            id: matched.fragment_id.clone(),
1190            inventoryable: ctx
1191                .protocol
1192                .components
1193                .get(&matched.fragment_id)
1194                .is_some_and(has_template_payload),
1195            route_base: child_base.clone(),
1196        });
1197
1198        if !matched.pending_component.is_empty() {
1199            stack.push(QueuedFragment {
1200                id: matched.pending_component.clone(),
1201                inventoryable: ctx
1202                    .protocol
1203                    .components
1204                    .get(&matched.pending_component)
1205                    .is_some_and(has_template_payload),
1206                route_base: child_base.clone(),
1207            });
1208        }
1209        if !matched.error_component.is_empty() {
1210            stack.push(QueuedFragment {
1211                id: matched.error_component.clone(),
1212                inventoryable: ctx
1213                    .protocol
1214                    .components
1215                    .get(&matched.error_component)
1216                    .is_some_and(has_template_payload),
1217                route_base: child_base.clone(),
1218            });
1219        }
1220
1221        if matched.children.is_empty() {
1222            break;
1223        }
1224        current = &matched.children;
1225        base = child_base;
1226    }
1227}
1228
1229/// Inventory the pending/error boundary components for an entire route subtree.
1230///
1231/// Unlike [`walk_route_children`], which follows only the best-matching chain,
1232/// this collects boundary components for *every* route reachable from `routes`.
1233/// Pending UI renders while a route's partial fetch is in flight and error UI
1234/// renders if that fetch fails, so the client must already hold these templates
1235/// for any navigable sibling — not only the currently active route. The walk is
1236/// iterative because the framework forbids recursion in core paths.
1237fn collect_route_boundary_components(
1238    routes: &[WebUIFragmentRoute],
1239    route_base: &str,
1240    protocol: &WebUIProtocol,
1241    stack: &mut Vec<QueuedFragment>,
1242) {
1243    let mut remaining: Vec<&WebUIFragmentRoute> = routes.iter().collect();
1244    while let Some(route) = remaining.pop() {
1245        for component in [&route.pending_component, &route.error_component] {
1246            if component.is_empty() {
1247                continue;
1248            }
1249            stack.push(QueuedFragment {
1250                id: component.clone(),
1251                inventoryable: protocol
1252                    .components
1253                    .get(component)
1254                    .is_some_and(has_template_payload),
1255                route_base: route_base.to_string(),
1256            });
1257        }
1258        remaining.extend(route.children.iter());
1259    }
1260}
1261
1262/// Walk the fragment graph following matched routes and collect all route
1263/// parameters from every level of the active route chain.
1264///
1265/// This is used by the dev server to inject nested route params into state.
1266pub fn collect_nested_route_params(
1267    protocol: &Protocol,
1268    entry_id: &str,
1269    request_path: &str,
1270) -> HashMap<String, String> {
1271    let route_index = protocol.route_index();
1272    let protocol = protocol.protocol();
1273    let mut all_params = HashMap::new();
1274    let mut visited_fragments = HashSet::new();
1275    let mut stack = vec![QueuedFragment {
1276        id: entry_id.to_string(),
1277        inventoryable: false,
1278        route_base: "/".to_string(),
1279    }];
1280
1281    while let Some(queued) = stack.pop() {
1282        if queued.id.is_empty() || !mark_fragment_visited(&mut visited_fragments, &queued, true) {
1283            continue;
1284        }
1285
1286        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1287            continue;
1288        };
1289
1290        let matched_route = route_renderer::find_best_route_match(
1291            &frag_list.fragments,
1292            request_path,
1293            &queued.route_base,
1294            route_index,
1295        );
1296
1297        for frag in &frag_list.fragments {
1298            match frag.fragment.as_ref() {
1299                Some(Fragment::Component(component)) => {
1300                    stack.push(QueuedFragment {
1301                        id: component.fragment_id.clone(),
1302                        inventoryable: false,
1303                        route_base: queued.route_base.clone(),
1304                    });
1305                }
1306                Some(Fragment::Route(route_frag)) => {
1307                    let is_selected = matched_route
1308                        .as_ref()
1309                        .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1310                    if is_selected && !route_frag.fragment_id.is_empty() {
1311                        if let Some((_, ref rm)) = matched_route {
1312                            // Collect params from this route level
1313                            all_params.extend(rm.params.clone());
1314
1315                            let child_route_base = route_matcher::compute_route_base(
1316                                request_path,
1317                                rm.consumed_segments,
1318                            );
1319
1320                            stack.push(QueuedFragment {
1321                                id: route_frag.fragment_id.clone(),
1322                                inventoryable: false,
1323                                route_base: child_route_base.clone(),
1324                            });
1325
1326                            // Walk nested children to collect params from deeper levels
1327                            collect_params_from_children(
1328                                &route_frag.children,
1329                                request_path,
1330                                &child_route_base,
1331                                &mut all_params,
1332                                route_index,
1333                            );
1334                        }
1335                    }
1336                }
1337                _ => {}
1338            }
1339        }
1340    }
1341
1342    all_params
1343}
1344
1345/// Iteratively collect route params from nested route children.
1346fn collect_params_from_children(
1347    children: &[WebUIFragmentRoute],
1348    request_path: &str,
1349    route_base: &str,
1350    all_params: &mut HashMap<String, String>,
1351    route_index: &CompiledRouteIndex,
1352) {
1353    let mut current = children;
1354    let mut base = route_base.to_string();
1355
1356    loop {
1357        let Some((idx, rm)) = select_best_child_route(current, request_path, &base, route_index)
1358        else {
1359            break;
1360        };
1361        all_params.extend(rm.params);
1362        let matched = &current[idx];
1363        if matched.children.is_empty() {
1364            break;
1365        }
1366        let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
1367        current = &matched.children;
1368        base = child_base;
1369    }
1370}
1371
1372// ── Partial Response ────────────────────────────────────────────────
1373
1374/// Resolve `{param}` placeholders in a list of tag templates.
1375///
1376/// Replaces `{paramName}` with the actual value from `params`.
1377/// Tags without placeholders are returned as-is. Missing params
1378/// result in the placeholder being left unresolved (defensive).
1379fn resolve_tag_templates(templates: &[String], params: &HashMap<String, String>) -> Vec<String> {
1380    let mut resolved = Vec::with_capacity(templates.len());
1381    for template in templates {
1382        if !template.contains('{') {
1383            resolved.push(template.clone());
1384            continue;
1385        }
1386        let mut result = String::with_capacity(template.len());
1387        let mut rest = template.as_str();
1388        while let Some(open) = rest.find('{') {
1389            result.push_str(&rest[..open]);
1390            rest = &rest[open + 1..];
1391            if let Some(close) = rest.find('}') {
1392                let name = &rest[..close];
1393                if let Some(value) = params.get(name) {
1394                    result.push_str(value);
1395                } else {
1396                    result.push('{');
1397                    result.push_str(name);
1398                    result.push('}');
1399                }
1400                rest = &rest[close + 1..];
1401            } else {
1402                result.push('{');
1403            }
1404        }
1405        result.push_str(rest);
1406        resolved.push(result);
1407    }
1408    resolved
1409}
1410
1411/// Single-pass graph walk that collects both inventoryable component names
1412/// and the matched route chain. Eliminates the duplicate graph traversal
1413/// that previously existed in `render_partial`.
1414fn collect_inventory_and_chain(
1415    protocol: &WebUIProtocol,
1416    entry_id: &str,
1417    request_path: &str,
1418    index: &mut RequestProtocolIndex<'_>,
1419) -> (Vec<String>, Vec<RouteChainEntry>) {
1420    let mut visited_fragments = HashSet::new();
1421    // Deduped ordered collection: `filter_needed_components` now takes a
1422    // slice of unique names. Order is irrelevant for this consumer
1423    // (`render_partial` re-sorts in `collect_component_assets`), but the
1424    // list must stay duplicate-free.
1425    let mut seen_components = HashSet::new();
1426    let mut component_ids: Vec<String> = Vec::new();
1427    let mut chain = Vec::new();
1428    let mut stack = vec![QueuedFragment {
1429        id: entry_id.to_string(),
1430        inventoryable: false,
1431        route_base: "/".to_string(),
1432    }];
1433
1434    while let Some(queued) = stack.pop() {
1435        if queued.id.is_empty() {
1436            continue;
1437        }
1438
1439        if queued.inventoryable && seen_components.insert(queued.id.clone()) {
1440            component_ids.push(queued.id.clone());
1441        }
1442
1443        if !mark_fragment_visited(&mut visited_fragments, &queued, true) {
1444            continue;
1445        }
1446
1447        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1448            continue;
1449        };
1450
1451        let matched_route = route_renderer::find_best_route_match(
1452            &frag_list.fragments,
1453            request_path,
1454            &queued.route_base,
1455            index.route_index,
1456        );
1457
1458        for frag in &frag_list.fragments {
1459            match frag.fragment.as_ref() {
1460                Some(Fragment::Component(component)) => {
1461                    // Inventory: components are inventoryable
1462                    stack.push(QueuedFragment {
1463                        id: component.fragment_id.clone(),
1464                        inventoryable: true,
1465                        route_base: queued.route_base.clone(),
1466                    });
1467                }
1468                Some(Fragment::ForLoop(for_loop)) => {
1469                    // Inventory: follow control-flow edges conservatively
1470                    stack.push(QueuedFragment {
1471                        id: for_loop.fragment_id.clone(),
1472                        inventoryable: false,
1473                        route_base: queued.route_base.clone(),
1474                    });
1475                }
1476                Some(Fragment::IfCond(if_cond)) => {
1477                    stack.push(QueuedFragment {
1478                        id: if_cond.fragment_id.clone(),
1479                        inventoryable: false,
1480                        route_base: queued.route_base.clone(),
1481                    });
1482                }
1483                Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
1484                    stack.push(QueuedFragment {
1485                        id: attr.template.clone(),
1486                        inventoryable: false,
1487                        route_base: queued.route_base.clone(),
1488                    });
1489                }
1490                Some(Fragment::Route(route_frag)) => {
1491                    let is_selected = matched_route
1492                        .as_ref()
1493                        .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1494                    if is_selected && !route_frag.fragment_id.is_empty() {
1495                        if let Some((_, ref rm)) = matched_route {
1496                            // Chain: record matched route entry
1497                            chain.push(RouteChainEntry {
1498                                component: route_frag.fragment_id.clone(),
1499                                path: route_frag.path.clone(),
1500                                params: rm.params.clone(),
1501                                exact: route_frag.exact,
1502                                allowed_query: route_frag.allowed_query.clone(),
1503                                keep_alive: route_frag.keep_alive,
1504                                cache_tags: route_frag.cache_tags.clone(),
1505                                invalidates: route_frag.invalidates.clone(),
1506                                pending_component: route_frag.pending_component.clone(),
1507                                error_component: route_frag.error_component.clone(),
1508                            });
1509
1510                            let child_route_base = route_matcher::compute_route_base(
1511                                request_path,
1512                                rm.consumed_segments,
1513                            );
1514
1515                            // Inventory: follow matched route component
1516                            let is_inventoryable = protocol
1517                                .components
1518                                .get(&route_frag.fragment_id)
1519                                .is_some_and(has_template_payload);
1520                            stack.push(QueuedFragment {
1521                                id: route_frag.fragment_id.clone(),
1522                                inventoryable: is_inventoryable,
1523                                route_base: child_route_base.clone(),
1524                            });
1525
1526                            collect_route_boundary_components(
1527                                std::slice::from_ref(route_frag),
1528                                &child_route_base,
1529                                protocol,
1530                                &mut stack,
1531                            );
1532
1533                            // Both: walk nested child routes
1534                            if !route_frag.children.is_empty() {
1535                                walk_children_for_inventory_and_chain(
1536                                    &route_frag.children,
1537                                    &child_route_base,
1538                                    &mut stack,
1539                                    &mut chain,
1540                                    &mut ChildWalkCtx {
1541                                        request_path,
1542                                        protocol,
1543                                        route_index: index.route_index,
1544                                    },
1545                                );
1546                            }
1547                        }
1548                    }
1549                }
1550                _ => {}
1551            }
1552        }
1553    }
1554
1555    (component_ids, chain)
1556}
1557
1558/// Walk nested route children, collecting both inventory and chain entries.
1559fn walk_children_for_inventory_and_chain(
1560    children: &[WebUIFragmentRoute],
1561    route_base: &str,
1562    stack: &mut Vec<QueuedFragment>,
1563    chain: &mut Vec<RouteChainEntry>,
1564    ctx: &mut ChildWalkCtx<'_>,
1565) {
1566    let mut current = children;
1567    let mut base = route_base.to_string();
1568
1569    loop {
1570        let Some((idx, ref rm)) =
1571            select_best_child_route(current, ctx.request_path, &base, ctx.route_index)
1572        else {
1573            break;
1574        };
1575        let matched = &current[idx];
1576        if matched.fragment_id.is_empty() {
1577            break;
1578        }
1579
1580        let child_base = route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
1581
1582        chain.push(RouteChainEntry {
1583            component: matched.fragment_id.clone(),
1584            path: matched.path.clone(),
1585            params: rm.params.clone(),
1586            exact: matched.exact,
1587            allowed_query: matched.allowed_query.clone(),
1588            keep_alive: matched.keep_alive,
1589            cache_tags: matched.cache_tags.clone(),
1590            invalidates: matched.invalidates.clone(),
1591            pending_component: matched.pending_component.clone(),
1592            error_component: matched.error_component.clone(),
1593        });
1594
1595        stack.push(QueuedFragment {
1596            id: matched.fragment_id.clone(),
1597            inventoryable: ctx
1598                .protocol
1599                .components
1600                .get(&matched.fragment_id)
1601                .is_some_and(has_template_payload),
1602            route_base: child_base.clone(),
1603        });
1604
1605        if matched.children.is_empty() {
1606            break;
1607        }
1608        current = &matched.children;
1609        base = child_base;
1610    }
1611}
1612
1613/// Produce state-free partial-navigation metadata for NDJSON chunk 1.
1614///
1615/// Returns the matched route chain, templates, inventory, and cache tags.
1616/// State is intentionally excluded so a streaming host can send it later.
1617/// Use [`render_partial`] for a complete JSON response.
1618///
1619/// Returns a `serde_json::Value` object with fields:
1620/// - `templateStyles`: module CSS definition tags for inventory-new components (empty for Link/Style)
1621/// - `templates`: client template metadata keyed by component tag (inventory-filtered)
1622/// - `templateFunctions`: component-local condition closure arrays keyed by component tag
1623/// - `inventory`: updated hex bitmask
1624/// - `path`: the request path
1625/// - `chain`: matched route chain array
1626/// - `cacheTags`: resolved cache tags from the full route chain (union of all levels)
1627#[cfg(test)]
1628fn render_partial_metadata(
1629    protocol: &WebUIProtocol,
1630    entry_id: &str,
1631    request_path: &str,
1632    inventory_hex: &str,
1633    index: &mut ProtocolIndex,
1634) -> Result<Value, HandlerError> {
1635    let mut request_index = index.request_index();
1636    render_partial_indexed(
1637        protocol,
1638        entry_id,
1639        request_path,
1640        inventory_hex,
1641        &mut request_index,
1642    )
1643}
1644
1645/// Produce a complete partial-navigation response with projected parsed state.
1646///
1647/// Only keys required to create or update client components reachable on the
1648/// active route are retained. Authored components use their explicit
1649/// navigation surface (`@observable + @attr + template roots`); scriptless
1650/// templates use compiled template roots.
1651#[cfg(test)]
1652fn render_partial(
1653    protocol: &WebUIProtocol,
1654    state: Value,
1655    entry_id: &str,
1656    request_path: &str,
1657    inventory_hex: &str,
1658) -> Result<Value, HandlerError> {
1659    let mut index = ProtocolIndex::new(protocol);
1660    let mut request_index = index.request_index();
1661    let (mut response, state_selection) = render_partial_indexed_with_state(
1662        protocol,
1663        entry_id,
1664        request_path,
1665        inventory_hex,
1666        &mut request_index,
1667    )?;
1668    let response = response
1669        .as_object_mut()
1670        .ok_or_else(partial_response_not_object)?;
1671    response.insert("state".into(), select_owned_state(state, &state_selection));
1672    Ok(Value::Object(std::mem::take(response)))
1673}
1674
1675#[cfg(test)]
1676fn render_partial_indexed(
1677    protocol: &WebUIProtocol,
1678    entry_id: &str,
1679    request_path: &str,
1680    inventory_hex: &str,
1681    index: &mut RequestProtocolIndex<'_>,
1682) -> Result<Value, HandlerError> {
1683    render_partial_indexed_with_state(protocol, entry_id, request_path, inventory_hex, index)
1684        .map(|(response, _)| response)
1685}
1686
1687fn render_partial_indexed_with_state<'a>(
1688    protocol: &'a WebUIProtocol,
1689    entry_id: &str,
1690    request_path: &str,
1691    inventory_hex: &str,
1692    index: &mut RequestProtocolIndex<'_>,
1693) -> Result<(Value, StateSelection<'a>), HandlerError> {
1694    // Single-pass walk: collect both inventory components and route chain.
1695    let (component_ids, mut chain) =
1696        collect_inventory_and_chain(protocol, entry_id, request_path, index);
1697    let state_selection =
1698        crate::collect_navigation_state(protocol, component_ids.iter().map(String::as_str));
1699
1700    let (needed_names, updated_inv) =
1701        filter_needed_components(&component_ids, inventory_hex, index.component_index)?;
1702
1703    // Resolve cache tags and invalidation templates with accumulated params.
1704    let mut accumulated_params: HashMap<String, String> = HashMap::new();
1705    let mut all_resolved_tags: Vec<String> = Vec::new();
1706
1707    for entry in &mut chain {
1708        for (k, v) in &entry.params {
1709            accumulated_params.insert(k.clone(), v.clone());
1710        }
1711        entry.cache_tags = resolve_tag_templates(&entry.cache_tags, &accumulated_params);
1712        entry.invalidates = resolve_tag_templates(&entry.invalidates, &accumulated_params);
1713        all_resolved_tags.extend(entry.cache_tags.iter().cloned());
1714    }
1715
1716    let tag_refs: Vec<&str> = needed_names.iter().map(|s| s.as_str()).collect();
1717    let assets = collect_component_assets(protocol, &tag_refs, index)?;
1718
1719    let chain_array = Value::Array(chain.iter().map(RouteChainEntry::to_json).collect());
1720
1721    let mut result = serde_json::Map::with_capacity(7);
1722    result.insert("templateStyles".into(), Value::Array(assets.styles));
1723    result.insert("templates".into(), Value::Object(assets.templates));
1724    result.insert("templateFunctions".into(), Value::Object(assets.functions));
1725    result.insert("inventory".into(), Value::String(updated_inv));
1726    result.insert("path".into(), Value::String(request_path.to_string()));
1727    result.insert("chain".into(), chain_array);
1728    if !all_resolved_tags.is_empty() {
1729        let mut seen = HashSet::new();
1730        let deduped: Vec<Value> = all_resolved_tags
1731            .into_iter()
1732            .filter(|t| seen.insert(t.clone()))
1733            .map(Value::String)
1734            .collect();
1735        result.insert("cacheTags".into(), Value::Array(deduped));
1736    }
1737    Ok((Value::Object(result), state_selection))
1738}
1739
1740#[cfg(test)]
1741fn select_owned_state(state: Value, selection: &StateSelection<'_>) -> Value {
1742    let state_keys = match selection {
1743        StateSelection::Full => return state,
1744        StateSelection::Keys(keys) => keys.as_slice(),
1745        StateSelection::BorrowedKeys(keys) => *keys,
1746    };
1747    let Value::Object(mut source) = state else {
1748        return Value::Object(Map::new());
1749    };
1750    let mut projected = Map::with_capacity(state_keys.len().min(source.len()));
1751    for &key in state_keys {
1752        if let Some(value) = source.remove(key) {
1753            projected.insert(key.to_owned(), value);
1754        }
1755    }
1756    Value::Object(projected)
1757}
1758
1759/// Produce a JSON response for a POST mutation action.
1760///
1761/// Walks the matched route chain, resolves `invalidates` tag templates
1762/// with actual param values, and returns them alongside the application
1763/// state. Host servers call this for `POST` requests to route paths.
1764///
1765/// Returns a `serde_json::Value` object with fields:
1766/// - `state`: the application state passed through
1767/// - `invalidateTags`: resolved invalidation tags from the matched leaf route
1768/// - `path`: the request path
1769#[cfg(test)]
1770fn render_action_response(
1771    protocol: &WebUIProtocol,
1772    state: Value,
1773    entry_id: &str,
1774    request_path: &str,
1775    index: &mut ProtocolIndex,
1776) -> Value {
1777    let mut request_index = index.request_index();
1778    render_action_response_indexed(protocol, state, entry_id, request_path, &mut request_index)
1779}
1780
1781#[cfg(test)]
1782fn render_action_response_indexed(
1783    protocol: &WebUIProtocol,
1784    state: Value,
1785    entry_id: &str,
1786    request_path: &str,
1787    index: &mut RequestProtocolIndex<'_>,
1788) -> Value {
1789    let chain = collect_route_chain(protocol, entry_id, request_path, index.route_index);
1790
1791    // Accumulate params across the chain for tag resolution
1792    let mut accumulated_params: HashMap<String, String> = HashMap::new();
1793    let mut all_invalidates: Vec<String> = Vec::new();
1794
1795    for entry in &chain {
1796        for (k, v) in &entry.params {
1797            accumulated_params.insert(k.clone(), v.clone());
1798        }
1799        let resolved = resolve_tag_templates(&entry.invalidates, &accumulated_params);
1800        all_invalidates.extend(resolved);
1801    }
1802
1803    // Deduplicate while preserving order
1804    let mut seen = HashSet::new();
1805    let deduped: Vec<Value> = all_invalidates
1806        .into_iter()
1807        .filter(|t| seen.insert(t.clone()))
1808        .map(Value::String)
1809        .collect();
1810
1811    let mut result = serde_json::Map::with_capacity(3);
1812    result.insert("state".into(), state);
1813    result.insert("invalidateTags".into(), Value::Array(deduped));
1814    result.insert("path".into(), Value::String(request_path.to_string()));
1815    Value::Object(result)
1816}
1817
1818/// Return compiled templates and CSS for specific components by tag name.
1819///
1820/// This supports on-demand loading of components that aren't part of the
1821/// route tree (e.g., dialogs, popovers) — the client calls this before
1822/// creating the element so templates + styles are registered without FOUC.
1823///
1824/// Uses the same inventory bitfield as partial navigation to avoid sending
1825/// templates the client already has.
1826#[cfg(test)]
1827fn render_component_templates(
1828    protocol: &WebUIProtocol,
1829    component_tags: &[&str],
1830    inventory_hex: &str,
1831    index: &mut ProtocolIndex,
1832) -> Result<Value, HandlerError> {
1833    let mut request_index = index.request_index();
1834    render_component_templates_indexed(protocol, component_tags, inventory_hex, &mut request_index)
1835}
1836
1837fn render_component_templates_indexed(
1838    protocol: &WebUIProtocol,
1839    component_tags: &[&str],
1840    inventory_hex: &str,
1841    index: &mut RequestProtocolIndex<'_>,
1842) -> Result<Value, HandlerError> {
1843    // Deduplicate preserving caller order; filter expects unique names.
1844    let mut seen: HashSet<&str> = HashSet::new();
1845    let requested: Vec<String> = component_tags
1846        .iter()
1847        .filter(|s| seen.insert(**s))
1848        .map(|s| (*s).to_string())
1849        .collect();
1850    let (needed, updated_inv) =
1851        filter_needed_components(&requested, inventory_hex, index.component_index)?;
1852
1853    let tag_refs: Vec<&str> = needed.iter().map(|s| s.as_str()).collect();
1854    let assets = collect_component_assets(protocol, &tag_refs, index)?;
1855
1856    let mut result = serde_json::Map::with_capacity(4);
1857    result.insert("templateStyles".into(), Value::Array(assets.styles));
1858    result.insert("templates".into(), Value::Object(assets.templates));
1859    result.insert("templateFunctions".into(), Value::Object(assets.functions));
1860    result.insert("inventory".into(), Value::String(updated_inv));
1861    Ok(Value::Object(result))
1862}
1863
1864/// Shared helper: collect templates and module CSS styles for a set of component tags.
1865fn collect_component_assets(
1866    protocol: &WebUIProtocol,
1867    tags: &[&str],
1868    index: &mut RequestProtocolIndex<'_>,
1869) -> Result<ComponentAssets, HandlerError> {
1870    let mut style_array = Vec::new();
1871    let mut tmpl_map = serde_json::Map::new();
1872    let mut function_map = serde_json::Map::new();
1873
1874    // Sort for deterministic output (reproducible responses, cache keys)
1875    let mut sorted_tags: Vec<&str> = tags.to_vec();
1876    sorted_tags.sort_unstable();
1877
1878    for tag in sorted_tags {
1879        let Some(component) = protocol.components.get(tag) else {
1880            continue;
1881        };
1882        if !has_template_payload(component) {
1883            continue;
1884        }
1885        if !component.css.is_empty() {
1886            // No nonce here — the per-request CSP nonce is attached
1887            // client-side by the router when it materializes each
1888            // importmap script tag into the DOM.
1889            let tag_html = crate::css_module::build_importmap_tag(tag, &component.css, None);
1890            style_array.push(Value::String(tag_html));
1891        }
1892        if !component.template_json.is_empty() {
1893            let template_value = cached_template_metadata(
1894                &mut index.template_metadata_cache,
1895                tag,
1896                &component.template_json,
1897            )?;
1898            tmpl_map.insert(tag.to_string(), template_value);
1899            if !component.template_functions.is_empty() {
1900                function_map.insert(
1901                    tag.to_string(),
1902                    Value::String(component.template_functions.clone()),
1903                );
1904            }
1905        } else {
1906            tmpl_map.insert(tag.to_string(), Value::String(component.template.clone()));
1907        }
1908    }
1909
1910    Ok(ComponentAssets {
1911        styles: style_array,
1912        templates: tmpl_map,
1913        functions: function_map,
1914    })
1915}
1916
1917fn cached_template_metadata(
1918    cache: &mut TemplateMetadataCache<'_>,
1919    tag: &str,
1920    template_json: &str,
1921) -> Result<Value, HandlerError> {
1922    match cache {
1923        #[cfg(test)]
1924        TemplateMetadataCache::Exclusive(cache) => {
1925            cached_template_metadata_exclusive(cache, tag, template_json)
1926        }
1927        TemplateMetadataCache::Shared(cache) => {
1928            if let Some(value) = cache
1929                .read()
1930                .map_err(|_| protocol_metadata_cache_poisoned())?
1931                .get(tag)
1932                .cloned()
1933            {
1934                return Ok(value);
1935            }
1936
1937            let value = parse_template_metadata(tag, template_json)?;
1938            let mut cache = cache
1939                .write()
1940                .map_err(|_| protocol_metadata_cache_poisoned())?;
1941            Ok(cache.entry(tag.to_string()).or_insert(value).clone())
1942        }
1943    }
1944}
1945
1946#[cfg(test)]
1947fn cached_template_metadata_exclusive(
1948    cache: &mut HashMap<String, Value>,
1949    tag: &str,
1950    template_json: &str,
1951) -> Result<Value, HandlerError> {
1952    if let Some(value) = cache.get(tag) {
1953        return Ok(value.clone());
1954    }
1955
1956    let value = parse_template_metadata(tag, template_json)?;
1957    cache.insert(tag.to_string(), value.clone());
1958    Ok(value)
1959}
1960
1961fn parse_template_metadata(tag: &str, template_json: &str) -> Result<Value, HandlerError> {
1962    serde_json::from_str(template_json).map_err(|error| {
1963        HandlerError::Rendering(format!(
1964            "failed to parse template metadata for {tag}: {error}"
1965        ))
1966    })
1967}
1968
1969#[cold]
1970#[inline(never)]
1971fn protocol_metadata_cache_poisoned() -> HandlerError {
1972    HandlerError::Invariant(
1973        "protocol metadata cache is unavailable after a previous panic".to_string(),
1974    )
1975}
1976
1977// ── Route Chain ─────────────────────────────────────────────────────
1978
1979/// A single entry in the matched route chain, one per nesting level.
1980#[derive(Debug, Clone)]
1981pub struct RouteChainEntry {
1982    /// Component tag name for this route level.
1983    pub component: String,
1984    /// The route path pattern (as declared in the template).
1985    pub path: String,
1986    /// Bound route parameters at this level.
1987    pub params: HashMap<String, String>,
1988    /// Whether this route requires an exact match.
1989    pub exact: bool,
1990    /// Comma-separated allowlist of query parameters forwarded as attributes.
1991    pub allowed_query: String,
1992    /// When true, the router keeps the component alive across navigations.
1993    pub keep_alive: bool,
1994    /// Cache tag templates from the proto (e.g. `["thread:{threadId}", "inbox"]`).
1995    pub cache_tags: Vec<String>,
1996    /// Invalidation tag templates from the proto.
1997    pub invalidates: Vec<String>,
1998    /// Component tag name for pending/loading UI.
1999    pub pending_component: String,
2000    /// Component tag name for error boundary UI.
2001    pub error_component: String,
2002}
2003
2004impl RouteChainEntry {
2005    /// Serialize this entry to a JSON value ready for inclusion in a partial response.
2006    #[must_use]
2007    pub fn to_json(&self) -> Value {
2008        let mut obj = serde_json::Map::with_capacity(9);
2009        obj.insert("component".into(), Value::String(self.component.clone()));
2010        obj.insert("path".into(), Value::String(self.path.clone()));
2011        if !self.params.is_empty() {
2012            let params_obj: serde_json::Map<String, Value> = self
2013                .params
2014                .iter()
2015                .map(|(k, v)| (k.clone(), Value::String(v.clone())))
2016                .collect();
2017            obj.insert("params".into(), Value::Object(params_obj));
2018        }
2019        if self.exact {
2020            obj.insert("exact".into(), Value::Bool(true));
2021        }
2022        if !self.allowed_query.is_empty() {
2023            obj.insert(
2024                "allowedQuery".into(),
2025                Value::String(self.allowed_query.clone()),
2026            );
2027        }
2028        if self.keep_alive {
2029            obj.insert("keepAlive".into(), Value::Bool(true));
2030        }
2031        if !self.pending_component.is_empty() {
2032            obj.insert(
2033                "pendingComponent".into(),
2034                Value::String(self.pending_component.clone()),
2035            );
2036        }
2037        if !self.error_component.is_empty() {
2038            obj.insert(
2039                "errorComponent".into(),
2040                Value::String(self.error_component.clone()),
2041            );
2042        }
2043        if !self.invalidates.is_empty() {
2044            obj.insert(
2045                "invalidates".into(),
2046                Value::Array(
2047                    self.invalidates
2048                        .iter()
2049                        .map(|s| Value::String(s.clone()))
2050                        .collect(),
2051                ),
2052            );
2053        }
2054        Value::Object(obj)
2055    }
2056}
2057
2058/// Collect the matched route chain for a request path.
2059///
2060/// Walks the fragment graph from `entry_id`, follows the matched route at
2061/// each nesting level, and returns a chain entry per matched level.
2062pub(crate) fn collect_route_chain(
2063    protocol: &WebUIProtocol,
2064    entry_id: &str,
2065    request_path: &str,
2066    route_index: &CompiledRouteIndex,
2067) -> Vec<RouteChainEntry> {
2068    let mut chain = Vec::new();
2069    let mut visited_fragments = HashSet::new();
2070    let mut stack = vec![QueuedFragment {
2071        id: entry_id.to_string(),
2072        inventoryable: false,
2073        route_base: "/".to_string(),
2074    }];
2075
2076    while let Some(queued) = stack.pop() {
2077        if queued.id.is_empty() || !mark_fragment_visited(&mut visited_fragments, &queued, true) {
2078            continue;
2079        }
2080
2081        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
2082            continue;
2083        };
2084
2085        let matched_route = route_renderer::find_best_route_match(
2086            &frag_list.fragments,
2087            request_path,
2088            &queued.route_base,
2089            route_index,
2090        );
2091
2092        for frag in &frag_list.fragments {
2093            match frag.fragment.as_ref() {
2094                Some(Fragment::Component(component)) => {
2095                    stack.push(QueuedFragment {
2096                        id: component.fragment_id.clone(),
2097                        inventoryable: false,
2098                        route_base: queued.route_base.clone(),
2099                    });
2100                }
2101                Some(Fragment::Route(route_frag)) => {
2102                    let is_selected = matched_route
2103                        .as_ref()
2104                        .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
2105                    if is_selected && !route_frag.fragment_id.is_empty() {
2106                        if let Some((_, ref rm)) = matched_route {
2107                            chain.push(RouteChainEntry {
2108                                component: route_frag.fragment_id.clone(),
2109                                path: route_frag.path.clone(),
2110                                params: rm.params.clone(),
2111                                exact: route_frag.exact,
2112                                allowed_query: route_frag.allowed_query.clone(),
2113                                keep_alive: route_frag.keep_alive,
2114                                cache_tags: route_frag.cache_tags.clone(),
2115                                invalidates: route_frag.invalidates.clone(),
2116                                pending_component: route_frag.pending_component.clone(),
2117                                error_component: route_frag.error_component.clone(),
2118                            });
2119
2120                            let child_route_base = route_matcher::compute_route_base(
2121                                request_path,
2122                                rm.consumed_segments,
2123                            );
2124
2125                            stack.push(QueuedFragment {
2126                                id: route_frag.fragment_id.clone(),
2127                                inventoryable: false,
2128                                route_base: child_route_base.clone(),
2129                            });
2130
2131                            // Walk nested children iteratively
2132                            collect_chain_from_children(
2133                                &route_frag.children,
2134                                &child_route_base,
2135                                &mut chain,
2136                                &mut ChildWalkCtx {
2137                                    request_path,
2138                                    protocol,
2139                                    route_index,
2140                                },
2141                            );
2142                        }
2143                    }
2144                }
2145                _ => {}
2146            }
2147        }
2148    }
2149
2150    chain
2151}
2152
2153/// Iteratively collect chain entries from nested route children.
2154fn collect_chain_from_children(
2155    children: &[WebUIFragmentRoute],
2156    route_base: &str,
2157    chain: &mut Vec<RouteChainEntry>,
2158    ctx: &mut ChildWalkCtx<'_>,
2159) {
2160    let mut pending: Vec<(&[WebUIFragmentRoute], String)> =
2161        vec![(children, route_base.to_string())];
2162
2163    while let Some((current, base)) = pending.pop() {
2164        if let Some((idx, rm)) =
2165            select_best_child_route(current, ctx.request_path, &base, ctx.route_index)
2166        {
2167            let matched = &current[idx];
2168            chain.push(RouteChainEntry {
2169                component: matched.fragment_id.clone(),
2170                path: matched.path.clone(),
2171                params: rm.params,
2172                exact: matched.exact,
2173                allowed_query: matched.allowed_query.clone(),
2174                keep_alive: matched.keep_alive,
2175                cache_tags: matched.cache_tags.clone(),
2176                invalidates: matched.invalidates.clone(),
2177                pending_component: matched.pending_component.clone(),
2178                error_component: matched.error_component.clone(),
2179            });
2180            if !matched.children.is_empty() {
2181                let child_base =
2182                    route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
2183                pending.push((&matched.children, child_base));
2184            }
2185        }
2186    }
2187}
2188
2189#[cfg(test)]
2190mod tests {
2191    use super::*;
2192    use std::collections::HashMap;
2193    use std::sync::{Arc, Barrier};
2194    use std::thread;
2195    use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
2196
2197    #[test]
2198    fn protocol_decodes_once_and_exposes_tokens() {
2199        let mut fragments = HashMap::new();
2200        fragments.insert(
2201            "index.html".to_string(),
2202            FragmentList {
2203                fragments: vec![WebUIFragment::raw("<p>Prepared</p>")],
2204            },
2205        );
2206        let protocol = WebUIProtocol::with_tokens(fragments, vec!["colorBrand".to_string()]);
2207        let bytes = protocol.to_protobuf().unwrap();
2208        let prepared = Protocol::from_protobuf(&bytes).unwrap();
2209
2210        assert!(prepared.protocol().fragments.contains_key("index.html"));
2211        assert_eq!(prepared.tokens(), ["colorBrand"]);
2212    }
2213
2214    #[test]
2215    fn protocol_partial_matches_direct_index_path() {
2216        let mut fragments = HashMap::new();
2217        fragments.insert(
2218            "index.html".to_string(),
2219            FragmentList {
2220                fragments: vec![WebUIFragment::route("/", "home-page")],
2221            },
2222        );
2223        fragments.insert(
2224            "home-page".to_string(),
2225            FragmentList {
2226                fragments: vec![WebUIFragment::raw("<p>Home</p>")],
2227            },
2228        );
2229        let protocol = WebUIProtocol::new(fragments);
2230        let mut direct_index = ProtocolIndex::new(&protocol);
2231        let direct =
2232            render_partial_metadata(&protocol, "index.html", "/", "", &mut direct_index).unwrap();
2233        let prepared = Protocol::new(protocol);
2234        let cached = prepared
2235            .render_partial_metadata("index.html", "/", "")
2236            .unwrap();
2237
2238        assert_eq!(cached, direct);
2239    }
2240
2241    #[test]
2242    fn protocol_partial_supports_concurrent_metadata_reads() {
2243        let mut fragments = HashMap::new();
2244        fragments.insert(
2245            "index.html".to_string(),
2246            FragmentList {
2247                fragments: vec![WebUIFragment::route("/", "home-page")],
2248            },
2249        );
2250        fragments.insert(
2251            "home-page".to_string(),
2252            FragmentList {
2253                fragments: vec![WebUIFragment::raw("<p>Home</p>")],
2254            },
2255        );
2256        let mut protocol = WebUIProtocol::new(fragments);
2257        protocol.components.insert(
2258            "home-page".to_string(),
2259            webui_protocol::ComponentData {
2260                template_json: r#"{"h":"<p>Home</p>"}"#.to_string(),
2261                ..Default::default()
2262            },
2263        );
2264
2265        let prepared = Arc::new(Protocol::new(protocol));
2266        let barrier = Arc::new(Barrier::new(4));
2267        let handles: Vec<_> = (0..4)
2268            .map(|_| {
2269                let prepared = Arc::clone(&prepared);
2270                let barrier = Arc::clone(&barrier);
2271                thread::spawn(move || {
2272                    barrier.wait();
2273                    for _ in 0..50 {
2274                        let response = prepared
2275                            .render_partial_metadata("index.html", "/", "")
2276                            .unwrap();
2277                        assert_eq!(response["templates"]["home-page"]["h"], "<p>Home</p>");
2278                    }
2279                })
2280            })
2281            .collect();
2282
2283        for handle in handles {
2284            handle.join().unwrap();
2285        }
2286    }
2287
2288    fn prepared_partial_protocol(hydration_keys: &[&str]) -> Protocol {
2289        let mut fragments = HashMap::new();
2290        fragments.insert(
2291            "index.html".to_string(),
2292            FragmentList {
2293                fragments: vec![WebUIFragment::route("/", "home-page")],
2294            },
2295        );
2296        fragments.insert(
2297            "home-page".to_string(),
2298            FragmentList {
2299                fragments: vec![WebUIFragment::raw("<p>Home</p>")],
2300            },
2301        );
2302        let mut protocol = WebUIProtocol::new(fragments);
2303        protocol.components.insert(
2304            "home-page".to_string(),
2305            webui_protocol::ComponentData {
2306                template_json: r#"{"h":"<p>Home</p>"}"#.to_string(),
2307                hydration_mode: webui_protocol::StateProjectionMode::Keys as i32,
2308                hydration_keys: hydration_keys
2309                    .iter()
2310                    .map(|key| (*key).to_string())
2311                    .collect(),
2312                navigation_mode: Some(webui_protocol::StateProjectionMode::Keys as i32),
2313                navigation_keys: hydration_keys
2314                    .iter()
2315                    .map(|key| (*key).to_string())
2316                    .collect(),
2317                ..Default::default()
2318            },
2319        );
2320        Protocol::new(protocol)
2321    }
2322
2323    fn prepared_full_state_partial_protocol() -> Protocol {
2324        let mut prepared = prepared_partial_protocol(&[]);
2325        let protocol = &mut prepared.protocol;
2326        if let Some(component) = protocol.components.get_mut("home-page") {
2327            component.navigation_mode = Some(webui_protocol::StateProjectionMode::All as i32);
2328            component.navigation_keys.clear();
2329        }
2330        prepared
2331    }
2332
2333    #[test]
2334    fn partial_state_serialization_preserves_validated_raw_json() {
2335        let prepared = prepared_partial_protocol(&["value"]);
2336        let output = prepared
2337            .render_partial(
2338                r#"{"serverOnly":"drop","value":1e2}"#,
2339                "index.html",
2340                "/",
2341                "",
2342            )
2343            .unwrap();
2344        assert!(output.contains(r#""state":{"value":1e2}"#));
2345        assert!(!output.contains("serverOnly"));
2346
2347        let parsed: Value = serde_json::from_str(&output).unwrap();
2348        assert_eq!(parsed["state"]["value"], 100.0);
2349    }
2350
2351    #[test]
2352    fn uncertain_partial_surface_preserves_complete_raw_state() {
2353        let prepared = prepared_full_state_partial_protocol();
2354        let output = prepared
2355            .render_partial(
2356                r#"{"serverOnly":"keep","value":1e2}"#,
2357                "index.html",
2358                "/",
2359                "",
2360            )
2361            .unwrap();
2362        assert!(output.contains(r#""state":{"serverOnly":"keep","value":1e2}"#));
2363    }
2364
2365    #[test]
2366    fn partial_state_serialization_rejects_invalid_json() {
2367        let prepared = prepared_partial_protocol(&[]);
2368        let error = prepared
2369            .render_partial(r#"{"broken":"#, "index.html", "/", "")
2370            .expect_err("invalid state JSON must fail");
2371        assert!(error.to_string().contains("invalid state JSON"));
2372    }
2373
2374    #[test]
2375    fn partial_state_serialization_emits_empty_state_without_client_components() {
2376        let prepared = Protocol::new(WebUIProtocol::default());
2377        let output = prepared
2378            .render_partial(r#"{"serverOnly":"drop"}"#, "index.html", "/", "")
2379            .unwrap();
2380        let parsed: Value = serde_json::from_str(&output).unwrap();
2381        assert_eq!(parsed["state"], serde_json::json!({}));
2382    }
2383
2384    #[test]
2385    fn partial_state_serialization_validates_selected_and_skipped_numbers() {
2386        let prepared = prepared_partial_protocol(&["value"]);
2387        for state in [r#"{"value":1e9999}"#, r#"{"serverOnly":1e9999,"value":1}"#] {
2388            let error = prepared
2389                .render_partial(state, "index.html", "/", "")
2390                .expect_err("out-of-range state numbers must fail");
2391            assert!(error.to_string().contains("invalid state JSON"));
2392        }
2393    }
2394
2395    #[test]
2396    fn partial_state_serialization_uses_last_duplicate_and_decodes_keys() {
2397        let prepared = prepared_partial_protocol(&["value"]);
2398        let output = prepared
2399            .render_partial(r#"{"value":1,"va\u006cue":2}"#, "index.html", "/", "")
2400            .unwrap();
2401        let parsed: Value = serde_json::from_str(&output).unwrap();
2402        assert_eq!(parsed["state"], serde_json::json!({"value": 2}));
2403    }
2404
2405    #[test]
2406    fn parsed_partial_state_projects_without_cloning_server_only_values() {
2407        let prepared = prepared_partial_protocol(&["value"]);
2408        let response = render_partial(
2409            prepared.protocol(),
2410            serde_json::json!({"serverOnly": [1, 2, 3], "value": "kept"}),
2411            "index.html",
2412            "/",
2413            "",
2414        )
2415        .unwrap();
2416        assert_eq!(response["state"], serde_json::json!({"value": "kept"}));
2417    }
2418
2419    #[test]
2420    fn uncertain_partial_surface_preserves_complete_parsed_state() {
2421        let prepared = prepared_full_state_partial_protocol();
2422        let state = serde_json::json!({"serverOnly": [1, 2, 3], "value": "kept"});
2423        let response =
2424            render_partial(prepared.protocol(), state.clone(), "index.html", "/", "").unwrap();
2425        assert_eq!(response["state"], state);
2426    }
2427
2428    #[test]
2429    fn partial_state_validation_matches_value_number_limits() {
2430        for json in [
2431            r#"{"value":1e9999}"#,
2432            r#"{"value":-1e9999}"#,
2433            r#"{"value":1e308}"#,
2434            r#"{"value":18446744073709551615}"#,
2435            r#"{"value":18446744073709551616}"#,
2436        ] {
2437            assert_eq!(
2438                validate_json(json).is_ok(),
2439                serde_json::from_str::<Value>(json).is_ok(),
2440                "validation mismatch for {json}"
2441            );
2442        }
2443    }
2444
2445    #[test]
2446    fn test_parse_encode_inventory_roundtrip() {
2447        let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
2448        let hex = encode_inventory(&original);
2449        let decoded = parse_inventory(&hex).unwrap();
2450        assert_eq!(original, decoded);
2451    }
2452
2453    #[test]
2454    fn test_parse_inventory_empty() {
2455        let result = parse_inventory("").unwrap();
2456        assert!(result.is_empty());
2457    }
2458
2459    #[test]
2460    fn test_parse_inventory_rejects_odd_length() {
2461        let result = parse_inventory("abc");
2462        assert!(result.is_err());
2463    }
2464
2465    #[test]
2466    fn test_parse_inventory_rejects_invalid_hex() {
2467        let result = parse_inventory("zz");
2468        assert!(result.is_err());
2469    }
2470
2471    #[test]
2472    fn test_bitfield_set_and_check() {
2473        let mut inv = vec![0u8; 4];
2474        set_component(&mut inv, 0);
2475        set_component(&mut inv, 7);
2476        set_component(&mut inv, 8);
2477        set_component(&mut inv, 15);
2478        assert!(has_component(&inv, 0));
2479        assert!(has_component(&inv, 7));
2480        assert!(has_component(&inv, 8));
2481        assert!(has_component(&inv, 15));
2482        assert!(!has_component(&inv, 1));
2483        assert!(!has_component(&inv, 9));
2484    }
2485
2486    #[test]
2487    fn test_filter_needed_components_no_false_positives() {
2488        // With sequential indices, no two components share a bit
2489        let mut index = HashMap::new();
2490        index.insert("email-message".to_string(), 0);
2491        index.insert("o-button".to_string(), 1);
2492        index.insert("o-avatar".to_string(), 2);
2493
2494        let names = vec!["email-message".to_string(), "o-button".to_string()];
2495
2496        // Only o-button (index 1) is loaded — bit 1 set
2497        let mut inv = vec![0u8; 1];
2498        set_component(&mut inv, 1); // o-button
2499        let inv_hex = encode_inventory(&inv);
2500
2501        let (needed, _) = filter_needed_components(&names, &inv_hex, &index).unwrap();
2502        assert_eq!(needed.len(), 1);
2503        assert!(
2504            needed.contains(&"email-message".to_string()),
2505            "email-message must be needed: {needed:?}"
2506        );
2507    }
2508
2509    #[test]
2510    fn test_get_needed_components_empty_inventory() {
2511        let mut fragments = HashMap::new();
2512        fragments.insert(
2513            "app-shell".to_string(),
2514            FragmentList {
2515                fragments: vec![WebUIFragment::component("my-card")],
2516            },
2517        );
2518        fragments.insert(
2519            "my-card".to_string(),
2520            FragmentList {
2521                fragments: vec![WebUIFragment::raw("card")],
2522            },
2523        );
2524
2525        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2526        let comp_index = build_component_index(&protocol);
2527        let (needed, _inv) =
2528            get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
2529        assert!(needed.contains(&"app-shell".to_string()));
2530        assert!(needed.contains(&"my-card".to_string()));
2531    }
2532
2533    #[test]
2534    fn test_get_needed_components_skips_inventory() {
2535        let mut fragments = HashMap::new();
2536        fragments.insert(
2537            "app-shell".to_string(),
2538            FragmentList {
2539                fragments: vec![WebUIFragment::component("my-card")],
2540            },
2541        );
2542        fragments.insert(
2543            "my-card".to_string(),
2544            FragmentList {
2545                fragments: vec![WebUIFragment::raw("card")],
2546            },
2547        );
2548
2549        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2550        protocol
2551            .components
2552            .entry("app-shell".to_string())
2553            .or_default()
2554            .template = "<t></t>".to_string();
2555        protocol
2556            .components
2557            .entry("my-card".to_string())
2558            .or_default()
2559            .template = "<t></t>".to_string();
2560
2561        let comp_index = build_component_index(&protocol);
2562        let (_needed, inv_hex) =
2563            get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
2564        let (needed2, _) =
2565            get_needed_components(&protocol, "app-shell", &inv_hex, &comp_index).unwrap();
2566        assert!(needed2.is_empty());
2567    }
2568
2569    #[test]
2570    fn test_get_needed_components_skips_control_fragments() {
2571        let mut fragments = HashMap::new();
2572        fragments.insert(
2573            "app-shell".to_string(),
2574            FragmentList {
2575                fragments: vec![
2576                    WebUIFragment::if_cond(
2577                        webui_protocol::ConditionExpr::identifier("showNav"),
2578                        "if-shell",
2579                    ),
2580                    WebUIFragment::for_loop("item", "items", "for-items"),
2581                    WebUIFragment::attribute_template("title", "attr-title"),
2582                ],
2583            },
2584        );
2585        fragments.insert(
2586            "if-shell".to_string(),
2587            FragmentList {
2588                fragments: vec![WebUIFragment::component("mp-category-nav")],
2589            },
2590        );
2591        fragments.insert(
2592            "for-items".to_string(),
2593            FragmentList {
2594                fragments: vec![WebUIFragment::component("mp-product-card")],
2595            },
2596        );
2597        fragments.insert(
2598            "attr-title".to_string(),
2599            FragmentList {
2600                fragments: vec![WebUIFragment::raw("Products")],
2601            },
2602        );
2603        fragments.insert(
2604            "mp-category-nav".to_string(),
2605            FragmentList {
2606                fragments: vec![WebUIFragment::raw("<nav></nav>")],
2607            },
2608        );
2609        fragments.insert(
2610            "mp-product-card".to_string(),
2611            FragmentList {
2612                fragments: vec![WebUIFragment::raw("<article></article>")],
2613            },
2614        );
2615
2616        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2617        let comp_index = build_component_index(&protocol);
2618        let needed: HashSet<String> =
2619            get_needed_components(&protocol, "app-shell", "", &comp_index)
2620                .unwrap()
2621                .0
2622                .into_iter()
2623                .collect();
2624
2625        assert!(needed.contains("app-shell"));
2626        assert!(needed.contains("mp-category-nav"));
2627        assert!(needed.contains("mp-product-card"));
2628        assert!(!needed.contains("if-shell"));
2629        assert!(!needed.contains("for-items"));
2630        assert!(!needed.contains("attr-title"));
2631    }
2632
2633    #[test]
2634    fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
2635        let mut fragments = HashMap::new();
2636        fragments.insert(
2637            "index.html".to_string(),
2638            FragmentList {
2639                fragments: vec![WebUIFragment::component("mp-app")],
2640            },
2641        );
2642        fragments.insert(
2643            "mp-app".to_string(),
2644            FragmentList {
2645                fragments: vec![
2646                    WebUIFragment::component("mp-category-nav"),
2647                    WebUIFragment::route_from(WebUiFragmentRoute {
2648                        path: "/search/:category".into(),
2649                        fragment_id: "mp-search-page".into(),
2650                        exact: true,
2651                        keep_alive: false,
2652                        ..Default::default()
2653                    }),
2654                    WebUIFragment::route_from(WebUiFragmentRoute {
2655                        path: "/product/:handle".into(),
2656                        fragment_id: "mp-product-page".into(),
2657                        exact: true,
2658                        keep_alive: false,
2659                        ..Default::default()
2660                    }),
2661                ],
2662            },
2663        );
2664        fragments.insert(
2665            "mp-category-nav".to_string(),
2666            FragmentList {
2667                fragments: vec![WebUIFragment::raw("<nav></nav>")],
2668            },
2669        );
2670        fragments.insert(
2671            "mp-search-page".to_string(),
2672            FragmentList {
2673                fragments: vec![WebUIFragment::component("mp-product-grid")],
2674            },
2675        );
2676        fragments.insert(
2677            "mp-product-grid".to_string(),
2678            FragmentList {
2679                fragments: vec![WebUIFragment::raw("<div>grid</div>")],
2680            },
2681        );
2682        fragments.insert(
2683            "mp-product-page".to_string(),
2684            FragmentList {
2685                fragments: vec![WebUIFragment::component("mp-product-detail")],
2686            },
2687        );
2688        fragments.insert(
2689            "mp-product-detail".to_string(),
2690            FragmentList {
2691                fragments: vec![WebUIFragment::raw("<div>detail</div>")],
2692            },
2693        );
2694
2695        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2696        protocol
2697            .components
2698            .entry("mp-search-page".to_string())
2699            .or_default()
2700            .template = "<mp-search-page></mp-search-page>".to_string();
2701        protocol
2702            .components
2703            .entry("mp-product-page".to_string())
2704            .or_default()
2705            .template = "<mp-product-page></mp-product-page>".to_string();
2706
2707        let comp_index = build_component_index(&protocol);
2708        let (needed, _inv) = get_needed_components_for_request(
2709            &protocol,
2710            "index.html",
2711            "/search/shirts",
2712            "",
2713            (&comp_index, &CompiledRouteIndex::new(&protocol)),
2714        )
2715        .unwrap();
2716        let needed: HashSet<String> = needed.into_iter().collect();
2717
2718        assert!(needed.contains("mp-app"));
2719        assert!(needed.contains("mp-category-nav"));
2720        assert!(needed.contains("mp-search-page"));
2721        assert!(needed.contains("mp-product-grid"));
2722        assert!(!needed.contains("mp-product-page"));
2723        assert!(!needed.contains("mp-product-detail"));
2724    }
2725
2726    #[test]
2727    fn test_get_needed_components_for_request_follows_nested_route_chain() {
2728        let mut fragments = HashMap::new();
2729        fragments.insert(
2730            "index.html".to_string(),
2731            FragmentList {
2732                fragments: vec![WebUIFragment::component("mp-app")],
2733            },
2734        );
2735        fragments.insert(
2736            "mp-app".to_string(),
2737            FragmentList {
2738                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2739                    path: "/account/*rest".into(),
2740                    fragment_id: "mp-account-shell".into(),
2741                    exact: false,
2742                    keep_alive: false,
2743                    ..Default::default()
2744                })],
2745            },
2746        );
2747        fragments.insert(
2748            "mp-account-shell".to_string(),
2749            FragmentList {
2750                fragments: vec![
2751                    WebUIFragment::component("mp-account-nav"),
2752                    WebUIFragment::route_from(WebUiFragmentRoute {
2753                        path: "/account/profile".into(),
2754                        fragment_id: "mp-profile-page".into(),
2755                        exact: true,
2756                        keep_alive: false,
2757                        ..Default::default()
2758                    }),
2759                    WebUIFragment::route_from(WebUiFragmentRoute {
2760                        path: "/account/orders/:id".into(),
2761                        fragment_id: "mp-order-page".into(),
2762                        exact: true,
2763                        keep_alive: false,
2764                        ..Default::default()
2765                    }),
2766                ],
2767            },
2768        );
2769        fragments.insert(
2770            "mp-account-nav".to_string(),
2771            FragmentList {
2772                fragments: vec![WebUIFragment::raw("<nav></nav>")],
2773            },
2774        );
2775        fragments.insert(
2776            "mp-profile-page".to_string(),
2777            FragmentList {
2778                fragments: vec![WebUIFragment::raw("<profile></profile>")],
2779            },
2780        );
2781        fragments.insert(
2782            "mp-order-page".to_string(),
2783            FragmentList {
2784                fragments: vec![WebUIFragment::component("mp-order-detail")],
2785            },
2786        );
2787        fragments.insert(
2788            "mp-order-detail".to_string(),
2789            FragmentList {
2790                fragments: vec![WebUIFragment::raw("<detail></detail>")],
2791            },
2792        );
2793
2794        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2795        protocol
2796            .components
2797            .entry("mp-account-shell".to_string())
2798            .or_default()
2799            .template = "<mp-account-shell></mp-account-shell>".to_string();
2800        protocol
2801            .components
2802            .entry("mp-profile-page".to_string())
2803            .or_default()
2804            .template = "<mp-profile-page></mp-profile-page>".to_string();
2805        protocol
2806            .components
2807            .entry("mp-order-page".to_string())
2808            .or_default()
2809            .template = "<mp-order-page></mp-order-page>".to_string();
2810
2811        let comp_index = build_component_index(&protocol);
2812        let (needed, _inv) = get_needed_components_for_request(
2813            &protocol,
2814            "index.html",
2815            "/account/orders/42",
2816            "",
2817            (&comp_index, &CompiledRouteIndex::new(&protocol)),
2818        )
2819        .unwrap();
2820        let needed: HashSet<String> = needed.into_iter().collect();
2821
2822        assert!(needed.contains("mp-app"));
2823        assert!(needed.contains("mp-account-shell"));
2824        assert!(needed.contains("mp-account-nav"));
2825        assert!(needed.contains("mp-order-page"));
2826        assert!(needed.contains("mp-order-detail"));
2827        assert!(!needed.contains("mp-profile-page"));
2828    }
2829
2830    #[test]
2831    fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
2832        let mut fragments = HashMap::new();
2833        fragments.insert(
2834            "index.html".to_string(),
2835            FragmentList {
2836                fragments: vec![WebUIFragment::component("mp-app")],
2837            },
2838        );
2839        fragments.insert(
2840            "mp-app".to_string(),
2841            FragmentList {
2842                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2843                    path: "/search".into(),
2844                    fragment_id: "mp-search-page".into(),
2845                    exact: true,
2846                    keep_alive: false,
2847                    ..Default::default()
2848                })],
2849            },
2850        );
2851        fragments.insert(
2852            "mp-search-page".to_string(),
2853            FragmentList {
2854                fragments: vec![
2855                    WebUIFragment::if_cond(
2856                        webui_protocol::ConditionExpr::identifier("showFilters"),
2857                        "if-filters",
2858                    ),
2859                    WebUIFragment::for_loop("item", "items", "item-loop"),
2860                ],
2861            },
2862        );
2863        fragments.insert(
2864            "if-filters".to_string(),
2865            FragmentList {
2866                fragments: vec![WebUIFragment::component("mp-filter-panel")],
2867            },
2868        );
2869        fragments.insert(
2870            "item-loop".to_string(),
2871            FragmentList {
2872                fragments: vec![WebUIFragment::component("mp-item-card")],
2873            },
2874        );
2875        fragments.insert(
2876            "mp-filter-panel".to_string(),
2877            FragmentList {
2878                fragments: vec![WebUIFragment::raw("<filters></filters>")],
2879            },
2880        );
2881        fragments.insert(
2882            "mp-item-card".to_string(),
2883            FragmentList {
2884                fragments: vec![WebUIFragment::raw("<item></item>")],
2885            },
2886        );
2887
2888        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2889        protocol
2890            .components
2891            .entry("mp-search-page".to_string())
2892            .or_default()
2893            .template = "<mp-search-page></mp-search-page>".to_string();
2894
2895        let comp_index = build_component_index(&protocol);
2896        let (needed, _inv) = get_needed_components_for_request(
2897            &protocol,
2898            "index.html",
2899            "/search",
2900            "",
2901            (&comp_index, &CompiledRouteIndex::new(&protocol)),
2902        )
2903        .unwrap();
2904        let needed: HashSet<String> = needed.into_iter().collect();
2905
2906        assert!(needed.contains("mp-app"));
2907        assert!(needed.contains("mp-search-page"));
2908        assert!(needed.contains("mp-filter-panel"));
2909        assert!(needed.contains("mp-item-card"));
2910    }
2911
2912    #[test]
2913    fn test_get_needed_components_for_request_respects_inventory() {
2914        let mut fragments = HashMap::new();
2915        fragments.insert(
2916            "index.html".to_string(),
2917            FragmentList {
2918                fragments: vec![WebUIFragment::component("mp-app")],
2919            },
2920        );
2921        fragments.insert(
2922            "mp-app".to_string(),
2923            FragmentList {
2924                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2925                    path: "/search".into(),
2926                    fragment_id: "mp-search-page".into(),
2927                    exact: true,
2928                    keep_alive: false,
2929                    ..Default::default()
2930                })],
2931            },
2932        );
2933        fragments.insert(
2934            "mp-search-page".to_string(),
2935            FragmentList {
2936                fragments: vec![WebUIFragment::component("mp-product-grid")],
2937            },
2938        );
2939        fragments.insert(
2940            "mp-product-grid".to_string(),
2941            FragmentList {
2942                fragments: vec![WebUIFragment::raw("<grid></grid>")],
2943            },
2944        );
2945
2946        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2947        for name in ["mp-app", "mp-search-page", "mp-product-grid"] {
2948            protocol
2949                .components
2950                .entry(name.to_string())
2951                .or_default()
2952                .template = format!("<f-template id=\"{name}\"></f-template>");
2953        }
2954
2955        let comp_index = build_component_index(&protocol);
2956        let route_index = CompiledRouteIndex::new(&protocol);
2957        let (_needed, inventory) = get_needed_components_for_request(
2958            &protocol,
2959            "index.html",
2960            "/search",
2961            "",
2962            (&comp_index, &route_index),
2963        )
2964        .unwrap();
2965        let (needed_again, _) = get_needed_components_for_request(
2966            &protocol,
2967            "index.html",
2968            "/search",
2969            &inventory,
2970            (&comp_index, &route_index),
2971        )
2972        .unwrap();
2973        assert!(needed_again.is_empty());
2974    }
2975
2976    #[test]
2977    fn test_inventory_includes_boundary_components_for_unmatched_sibling_routes() {
2978        // Regression: pending/error components for routes that are NOT in the
2979        // matched chain at the current request path must still be inventoried.
2980        // The client renders pending UI before a sibling route's partial
2981        // resolves and error UI when it fails, so neither can be delivered by
2982        // the target route's own fetch — both must already be on the client.
2983        let mut fragments = HashMap::new();
2984        fragments.insert(
2985            "index.html".to_string(),
2986            FragmentList {
2987                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2988                    path: "/".into(),
2989                    fragment_id: "route-shell".into(),
2990                    children: vec![
2991                        WebUiFragmentRoute {
2992                            path: "slow".into(),
2993                            fragment_id: "page-slow".into(),
2994                            exact: true,
2995                            pending_component: "loading-skeleton".into(),
2996                            ..Default::default()
2997                        },
2998                        WebUiFragmentRoute {
2999                            path: "failing".into(),
3000                            fragment_id: "page-failing".into(),
3001                            exact: true,
3002                            error_component: "error-display".into(),
3003                            ..Default::default()
3004                        },
3005                    ],
3006                    ..Default::default()
3007                })],
3008            },
3009        );
3010        for id in [
3011            "route-shell",
3012            "page-slow",
3013            "page-failing",
3014            "loading-skeleton",
3015            "error-display",
3016        ] {
3017            fragments.insert(
3018                id.to_string(),
3019                FragmentList {
3020                    fragments: vec![WebUIFragment::raw("<x></x>")],
3021                },
3022            );
3023        }
3024
3025        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3026        for id in [
3027            "route-shell",
3028            "page-slow",
3029            "page-failing",
3030            "loading-skeleton",
3031            "error-display",
3032        ] {
3033            protocol
3034                .components
3035                .entry(id.to_string())
3036                .or_default()
3037                .template = format!("<f-template id=\"{id}\"></f-template>");
3038        }
3039
3040        // Request the root path: neither "slow" nor "failing" is in the matched chain.
3041        let reachable = collect_reachable_components_for_request(
3042            &protocol,
3043            "index.html",
3044            "/",
3045            &CompiledRouteIndex::new(&protocol),
3046        );
3047        assert!(
3048            reachable.contains("loading-skeleton"),
3049            "pending component of an unmatched sibling route must be inventoried: {reachable:?}"
3050        );
3051        assert!(
3052            reachable.contains("error-display"),
3053            "error component of an unmatched sibling route must be inventoried: {reachable:?}"
3054        );
3055    }
3056
3057    #[test]
3058    fn component_reachability_propagates_route_dependency_to_parent() {
3059        let protocol = WebUIProtocol::new(HashMap::from([
3060            (
3061                "comp-a".to_string(),
3062                FragmentList {
3063                    fragments: vec![WebUIFragment::component("comp-b")],
3064                },
3065            ),
3066            (
3067                "comp-b".to_string(),
3068                FragmentList {
3069                    fragments: vec![WebUIFragment::route("/", "page-a")],
3070                },
3071            ),
3072            (
3073                "page-a".to_string(),
3074                FragmentList {
3075                    fragments: vec![WebUIFragment::raw("<p>page</p>")],
3076                },
3077            ),
3078        ]));
3079        let component_index = build_component_index(&protocol);
3080        let reachability = ComponentReachabilityIndex::new(&protocol, &component_index);
3081
3082        assert_eq!(
3083            reachability.is_route_dependent(component_index["comp-a"]),
3084            Some(true)
3085        );
3086        assert_eq!(
3087            reachability.is_route_dependent(component_index["comp-b"]),
3088            Some(true)
3089        );
3090        assert_eq!(
3091            reachability.is_route_dependent(component_index["page-a"]),
3092            Some(false)
3093        );
3094    }
3095
3096    #[test]
3097    fn request_reachability_visits_reused_route_component_at_each_base() {
3098        let mut protocol = WebUIProtocol::new(HashMap::from([
3099            (
3100                "shared-shell".to_string(),
3101                FragmentList {
3102                    fragments: vec![WebUIFragment::route("details", "detail-page")],
3103                },
3104            ),
3105            (
3106                "detail-page".to_string(),
3107                FragmentList {
3108                    fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
3109                },
3110            ),
3111        ]));
3112        for tag in ["shared-shell", "detail-page"] {
3113            protocol
3114                .components
3115                .entry(tag.to_string())
3116                .or_default()
3117                .template = "<template></template>".to_string();
3118        }
3119        let route_index = CompiledRouteIndex::new(&protocol);
3120        let handler_protocol = Protocol::new(protocol.clone());
3121        let reachability = handler_protocol.component_reachability();
3122        let shell = handler_protocol.component_index()["shared-shell"];
3123        let roots = [
3124            (shell, Some(Box::from("/other"))),
3125            (shell, Some(Box::from("/account"))),
3126        ];
3127
3128        let reachable = collect_reachable_components_from_roots(
3129            &protocol,
3130            &roots,
3131            reachability,
3132            "/account/details",
3133            &route_index,
3134        );
3135
3136        assert!(
3137            reachable.iter().any(|tag| tag == "detail-page"),
3138            "the second route base must not be hidden by fragment-id deduplication: {reachable:?}"
3139        );
3140    }
3141
3142    #[test]
3143    fn component_reachability_index_is_lazy() {
3144        let protocol = Protocol::new(WebUIProtocol::new(HashMap::from([(
3145            "comp-a".to_string(),
3146            FragmentList {
3147                fragments: vec![WebUIFragment::raw("<p>leaf</p>")],
3148            },
3149        )])));
3150
3151        assert!(protocol.component_reachability.get().is_none());
3152        assert_eq!(
3153            protocol
3154                .component_reachability()
3155                .is_route_dependent(protocol.component_index["comp-a"]),
3156            Some(false)
3157        );
3158        assert!(protocol.component_reachability.get().is_some());
3159    }
3160
3161    #[test]
3162    fn test_render_partial_separates_module_styles_from_templates() {
3163        let mut fragments = HashMap::new();
3164        fragments.insert(
3165            "index.html".to_string(),
3166            FragmentList {
3167                fragments: vec![WebUIFragment::component("my-page")],
3168            },
3169        );
3170        fragments.insert(
3171            "my-page".to_string(),
3172            FragmentList {
3173                fragments: vec![WebUIFragment::raw("<p>page</p>")],
3174            },
3175        );
3176
3177        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3178        let component = protocol
3179            .components
3180            .entry("my-page".to_string())
3181            .or_default();
3182        component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
3183        component.css = ".page{color:red}".to_string();
3184
3185        let mut index = ProtocolIndex::new(&protocol);
3186        let partial =
3187            render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
3188        let styles = partial["templateStyles"]
3189            .as_array()
3190            .expect("templateStyles should be an array");
3191        assert_eq!(styles.len(), 1);
3192        let style_html = styles[0].as_str().unwrap_or_default();
3193        assert!(
3194            style_html.starts_with(r#"<script type="importmap""#)
3195                && style_html.contains(r#""my-page":"data:text/css,"#),
3196            "module style entry should be an importmap registering the component specifier: {style_html}"
3197        );
3198        assert!(
3199            style_html.contains(".page{color:red}"),
3200            "module style entry should contain the CSS content verbatim inside the data: URI: {style_html}"
3201        );
3202
3203        // templates should contain only JSON-safe metadata
3204        let templates = partial["templates"]
3205            .as_object()
3206            .expect("templates should be an object");
3207        assert_eq!(templates.len(), 1);
3208        let template = templates.get("my-page").expect("my-page template");
3209        assert_eq!(template["h"], "<p>page</p>");
3210    }
3211
3212    #[test]
3213    fn test_render_partial_link_strategy_has_empty_template_styles() {
3214        // Link-strategy components have css_href but no css content.
3215        // templateStyles should be empty; templates should contain metadata.
3216        let mut fragments = HashMap::new();
3217        fragments.insert(
3218            "index.html".to_string(),
3219            FragmentList {
3220                fragments: vec![WebUIFragment::component("my-page")],
3221            },
3222        );
3223        fragments.insert(
3224            "my-page".to_string(),
3225            FragmentList {
3226                fragments: vec![WebUIFragment::raw("<p>page</p>")],
3227            },
3228        );
3229
3230        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3231        let component = protocol
3232            .components
3233            .entry("my-page".to_string())
3234            .or_default();
3235        component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
3236        component.css_href = "my-page.css".to_string();
3237        // css is empty — Link strategy stores href, not content
3238
3239        let mut index = ProtocolIndex::new(&protocol);
3240        let partial =
3241            render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
3242        let styles = partial["templateStyles"]
3243            .as_array()
3244            .expect("templateStyles should be an array");
3245        let templates = partial["templates"]
3246            .as_object()
3247            .expect("templates should be an object");
3248
3249        assert!(
3250            styles.is_empty(),
3251            "Link strategy should produce empty templateStyles: {styles:?}"
3252        );
3253        assert_eq!(templates.len(), 1, "should include template metadata");
3254    }
3255
3256    #[test]
3257    fn test_render_partial_style_strategy_has_empty_template_styles() {
3258        // Style-strategy components have CSS embedded in the template HTML
3259        // (as <style>...</style>), not in component.css.
3260        // templateStyles should be empty; templates should contain metadata.
3261        let mut fragments = HashMap::new();
3262        fragments.insert(
3263            "index.html".to_string(),
3264            FragmentList {
3265                fragments: vec![WebUIFragment::component("my-page")],
3266            },
3267        );
3268        fragments.insert(
3269            "my-page".to_string(),
3270            FragmentList {
3271                fragments: vec![WebUIFragment::raw("<p>page</p>")],
3272            },
3273        );
3274
3275        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3276        let component = protocol
3277            .components
3278            .entry("my-page".to_string())
3279            .or_default();
3280        // Style strategy: CSS is inside the template HTML, not in component.css
3281        component.template_json = r#"{"h":"<style>.p{color:red}</style><p>page</p>"}"#.to_string();
3282        // css is empty for Style strategy
3283
3284        let mut index = ProtocolIndex::new(&protocol);
3285        let partial =
3286            render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
3287        let styles = partial["templateStyles"]
3288            .as_array()
3289            .expect("templateStyles should be an array");
3290        let templates = partial["templates"]
3291            .as_object()
3292            .expect("templates should be an object");
3293
3294        assert!(
3295            styles.is_empty(),
3296            "Style strategy should produce empty templateStyles: {styles:?}"
3297        );
3298        assert_eq!(templates.len(), 1, "should include template metadata");
3299        assert!(
3300            templates
3301                .get("my-page")
3302                .and_then(|template| template["h"].as_str())
3303                .unwrap_or_default()
3304                .contains("<style>"),
3305            "Style strategy template should contain inline <style> tag"
3306        );
3307    }
3308
3309    #[test]
3310    fn test_render_partial_empty_styles_for_no_css_components() {
3311        let mut fragments = HashMap::new();
3312        fragments.insert(
3313            "index.html".to_string(),
3314            FragmentList {
3315                fragments: vec![WebUIFragment::component("my-page")],
3316            },
3317        );
3318        fragments.insert(
3319            "my-page".to_string(),
3320            FragmentList {
3321                fragments: vec![WebUIFragment::raw("<p>page</p>")],
3322            },
3323        );
3324
3325        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3326        let component = protocol
3327            .components
3328            .entry("my-page".to_string())
3329            .or_default();
3330        component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
3331        // No CSS — simulates Link or Style mode
3332
3333        let mut index = ProtocolIndex::new(&protocol);
3334        let partial =
3335            render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
3336        let styles = partial["templateStyles"]
3337            .as_array()
3338            .expect("templateStyles should be an array");
3339        assert!(
3340            styles.is_empty(),
3341            "templateStyles should be empty when components have no CSS"
3342        );
3343    }
3344
3345    #[test]
3346    fn test_render_partial_sends_styles_even_when_templates_filtered_by_inventory() {
3347        let mut fragments = HashMap::new();
3348        fragments.insert(
3349            "index.html".to_string(),
3350            FragmentList {
3351                fragments: vec![WebUIFragment::component("my-page")],
3352            },
3353        );
3354        fragments.insert(
3355            "my-page".to_string(),
3356            FragmentList {
3357                fragments: vec![WebUIFragment::raw("<p>page</p>")],
3358            },
3359        );
3360
3361        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3362        let component = protocol
3363            .components
3364            .entry("my-page".to_string())
3365            .or_default();
3366        component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
3367        component.css = ".page{color:red}".to_string();
3368
3369        let mut index = ProtocolIndex::new(&protocol);
3370        // First call to establish inventory
3371        let partial1 =
3372            render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
3373        let inv = partial1["inventory"].as_str().unwrap_or_default();
3374        assert!(!inv.is_empty());
3375
3376        // Second call with the inventory — both templates and styles should be empty
3377        // because the inventory covers this component.  The SSR handler emits all
3378        // module style definitions in <head> for inventoried components, so the
3379        // client already has the CSS definition.
3380        let partial2 =
3381            render_partial_metadata(&protocol, "index.html", "/", inv, &mut index).unwrap();
3382        let styles = partial2["templateStyles"]
3383            .as_array()
3384            .expect("templateStyles should be an array");
3385        let templates = partial2["templates"]
3386            .as_object()
3387            .expect("templates should be an object");
3388
3389        assert!(
3390            templates.is_empty(),
3391            "templates should be empty when inventory is full"
3392        );
3393        assert!(
3394            styles.is_empty(),
3395            "module styles should be empty when inventory is full — SSR already placed them"
3396        );
3397    }
3398
3399    #[test]
3400    fn test_non_route_siblings_included_in_needed_components() {
3401        // Reproduces the commerce app layout: mp-app has both route children
3402        // (via outlet) AND non-route sibling components (mp-cart-panel, mp-footer).
3403        // All siblings must be in the needed set so their module CSS definitions
3404        // are emitted in <head> during SSR.
3405        let mut fragments = HashMap::new();
3406
3407        // Entry → app-shell component
3408        fragments.insert(
3409            "index.html".to_string(),
3410            FragmentList {
3411                fragments: vec![WebUIFragment::component("app-shell")],
3412            },
3413        );
3414
3415        // app-shell has: navbar (component), route (outlet), cart-panel (component)
3416        fragments.insert(
3417            "app-shell".to_string(),
3418            FragmentList {
3419                fragments: vec![
3420                    WebUIFragment::component("my-navbar"),
3421                    WebUIFragment::route_from(WebUiFragmentRoute {
3422                        path: "/about".into(),
3423                        fragment_id: "page-about".into(),
3424                        exact: true,
3425                        keep_alive: false,
3426                        ..Default::default()
3427                    }),
3428                    WebUIFragment::component("cart-panel"),
3429                ],
3430            },
3431        );
3432
3433        // Leaf fragments
3434        fragments.insert(
3435            "my-navbar".to_string(),
3436            FragmentList {
3437                fragments: vec![WebUIFragment::raw("<nav/>")],
3438            },
3439        );
3440        fragments.insert(
3441            "page-about".to_string(),
3442            FragmentList {
3443                fragments: vec![WebUIFragment::raw("<h1>About</h1>")],
3444            },
3445        );
3446        fragments.insert(
3447            "cart-panel".to_string(),
3448            FragmentList {
3449                fragments: vec![WebUIFragment::raw("<aside>Cart</aside>")],
3450            },
3451        );
3452
3453        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3454        for name in ["app-shell", "my-navbar", "page-about", "cart-panel"] {
3455            let comp = protocol.components.entry(name.to_string()).or_default();
3456            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
3457            comp.css = format!(".{name}{{display:block}}");
3458        }
3459
3460        let comp_index = build_component_index(&protocol);
3461        let (needed, _inv) = get_needed_components_for_request(
3462            &protocol,
3463            "index.html",
3464            "/about",
3465            "",
3466            (&comp_index, &CompiledRouteIndex::new(&protocol)),
3467        )
3468        .unwrap();
3469
3470        assert!(
3471            needed.contains(&"app-shell".to_string()),
3472            "app-shell should be needed: {needed:?}"
3473        );
3474        assert!(
3475            needed.contains(&"my-navbar".to_string()),
3476            "my-navbar (non-route sibling) should be needed: {needed:?}"
3477        );
3478        assert!(
3479            needed.contains(&"page-about".to_string()),
3480            "page-about (active route) should be needed: {needed:?}"
3481        );
3482        assert!(
3483            needed.contains(&"cart-panel".to_string()),
3484            "cart-panel (non-route sibling) should be needed: {needed:?}"
3485        );
3486    }
3487
3488    // ── RouteChainEntry.to_json + allowed_query tests ────────────────
3489
3490    #[test]
3491    fn test_chain_entry_to_json_includes_allowed_query() {
3492        let entry = RouteChainEntry {
3493            component: "compose-page".into(),
3494            path: "compose".into(),
3495            params: HashMap::new(),
3496            exact: true,
3497            allowed_query: "action,to,subject".into(),
3498            keep_alive: false,
3499            cache_tags: Vec::new(),
3500            invalidates: Vec::new(),
3501            pending_component: String::new(),
3502            error_component: String::new(),
3503        };
3504        let json = entry.to_json();
3505        assert_eq!(json["allowedQuery"], "action,to,subject");
3506    }
3507
3508    #[test]
3509    fn test_chain_entry_to_json_omits_empty_allowed_query() {
3510        let entry = RouteChainEntry {
3511            component: "home-page".into(),
3512            path: "/".into(),
3513            params: HashMap::new(),
3514            exact: true,
3515            allowed_query: String::new(),
3516            keep_alive: false,
3517            cache_tags: Vec::new(),
3518            invalidates: Vec::new(),
3519            pending_component: String::new(),
3520            error_component: String::new(),
3521        };
3522        let json = entry.to_json();
3523        assert!(
3524            json.get("allowedQuery").is_none(),
3525            "empty allowed_query should not appear in JSON"
3526        );
3527    }
3528
3529    #[test]
3530    fn test_collect_route_chain_carries_allowed_query() {
3531        let mut fragments = HashMap::new();
3532        fragments.insert(
3533            "index.html".to_string(),
3534            FragmentList {
3535                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3536                    path: "/".into(),
3537                    fragment_id: "app-shell".into(),
3538                    exact: false,
3539                    children: vec![WebUiFragmentRoute {
3540                        path: "compose".into(),
3541                        fragment_id: "compose-page".into(),
3542                        exact: true,
3543                        allowed_query: "action,to".into(),
3544                        keep_alive: false,
3545                        ..Default::default()
3546                    }],
3547                    keep_alive: false,
3548                    ..Default::default()
3549                })],
3550            },
3551        );
3552        fragments.insert(
3553            "app-shell".to_string(),
3554            FragmentList {
3555                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
3556            },
3557        );
3558        fragments.insert(
3559            "compose-page".to_string(),
3560            FragmentList {
3561                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
3562            },
3563        );
3564        let mut protocol = WebUIProtocol::new(fragments);
3565        protocol
3566            .components
3567            .entry("app-shell".into())
3568            .or_default()
3569            .css_href = "/app-shell.css".into();
3570        protocol
3571            .components
3572            .entry("compose-page".into())
3573            .or_default()
3574            .template_json = r#"{"h":"<p>Compose</p>"}"#.into();
3575
3576        let chain = collect_route_chain(
3577            &protocol,
3578            "index.html",
3579            "/compose",
3580            &CompiledRouteIndex::new(&protocol),
3581        );
3582        assert_eq!(chain.len(), 2);
3583        assert_eq!(chain[0].component, "app-shell");
3584        assert!(chain[0].allowed_query.is_empty());
3585        assert_eq!(chain[1].component, "compose-page");
3586        assert_eq!(chain[1].allowed_query, "action,to");
3587    }
3588
3589    #[test]
3590    fn scriptless_partial_uses_navigation_keys_without_bootstrap_keys() {
3591        let mut fragments = HashMap::new();
3592        fragments.insert(
3593            "index.html".to_string(),
3594            FragmentList {
3595                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3596                    path: "/items".into(),
3597                    fragment_id: "items-page".into(),
3598                    exact: true,
3599                    ..Default::default()
3600                })],
3601            },
3602        );
3603        fragments.insert(
3604            "items-page".to_string(),
3605            FragmentList {
3606                fragments: vec![WebUIFragment::raw("<p>Items</p>")],
3607            },
3608        );
3609        let mut protocol = WebUIProtocol::new(fragments);
3610        protocol.components.insert(
3611            "items-page".to_string(),
3612            webui_protocol::ComponentData {
3613                template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
3614                navigation_mode: Some(webui_protocol::StateProjectionMode::Keys as i32),
3615                navigation_keys: vec!["items".into(), "title".into()],
3616                ..Default::default()
3617            },
3618        );
3619
3620        let partial = render_partial(
3621            &protocol,
3622            serde_json::json!({
3623                "items": [1, 2],
3624                "serverOnly": "secret",
3625                "title": "Catalog",
3626            }),
3627            "index.html",
3628            "/items",
3629            "",
3630        )
3631        .unwrap();
3632
3633        assert_eq!(
3634            partial["state"],
3635            serde_json::json!({
3636                "items": [1, 2],
3637                "title": "Catalog",
3638            })
3639        );
3640    }
3641
3642    #[test]
3643    fn partial_requires_explicit_navigation_keys() {
3644        let mut fragments = HashMap::new();
3645        fragments.insert(
3646            "index.html".to_string(),
3647            FragmentList {
3648                fragments: vec![WebUIFragment::component("authored-card")],
3649            },
3650        );
3651        fragments.insert(
3652            "authored-card".to_string(),
3653            FragmentList {
3654                fragments: vec![WebUIFragment::raw("<p>Card</p>")],
3655            },
3656        );
3657        let mut protocol = WebUIProtocol::new(fragments);
3658        protocol.components.insert(
3659            "authored-card".to_string(),
3660            webui_protocol::ComponentData {
3661                hydration_mode: webui_protocol::StateProjectionMode::Keys as i32,
3662                hydration_keys: vec!["title".into()],
3663                navigation_mode: Some(webui_protocol::StateProjectionMode::None as i32),
3664                ..Default::default()
3665            },
3666        );
3667
3668        let partial = render_partial(
3669            &protocol,
3670            serde_json::json!({
3671                "serverOnly": "secret",
3672                "title": "Authored",
3673            }),
3674            "index.html",
3675            "/",
3676            "",
3677        )
3678        .unwrap();
3679
3680        assert_eq!(partial["state"], serde_json::json!({}));
3681    }
3682
3683    #[test]
3684    fn fully_static_scriptless_partial_emits_empty_state() {
3685        let mut fragments = HashMap::new();
3686        fragments.insert(
3687            "index.html".to_string(),
3688            FragmentList {
3689                fragments: vec![WebUIFragment::component("static-card")],
3690            },
3691        );
3692        fragments.insert(
3693            "static-card".to_string(),
3694            FragmentList {
3695                fragments: vec![WebUIFragment::raw("<p>Static</p>")],
3696            },
3697        );
3698        let mut protocol = WebUIProtocol::new(fragments);
3699        protocol.components.insert(
3700            "static-card".to_string(),
3701            webui_protocol::ComponentData {
3702                template_json: r#"{"h":"<p>Static</p>","th":1}"#.into(),
3703                navigation_mode: Some(webui_protocol::StateProjectionMode::None as i32),
3704                ..Default::default()
3705            },
3706        );
3707
3708        let partial = render_partial(
3709            &protocol,
3710            serde_json::json!({ "serverOnly": "secret" }),
3711            "index.html",
3712            "/",
3713            "",
3714        )
3715        .unwrap();
3716
3717        assert_eq!(partial["state"], serde_json::json!({}));
3718    }
3719
3720    #[test]
3721    fn test_render_component_templates_returns_template_and_css() {
3722        let mut fragments = HashMap::new();
3723        fragments.insert(
3724            "settings-dialog".to_string(),
3725            FragmentList {
3726                fragments: vec![WebUIFragment::raw("<div class='dialog'>Settings</div>")],
3727            },
3728        );
3729        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3730        let comp = protocol
3731            .components
3732            .entry("settings-dialog".to_string())
3733            .or_default();
3734        comp.template_json = r#"{"h":"<div>Settings</div>"}"#.to_string();
3735        comp.css = ".dialog{position:fixed}".to_string();
3736
3737        let mut index = ProtocolIndex::new(&protocol);
3738        let result =
3739            render_component_templates(&protocol, &["settings-dialog"], "", &mut index).unwrap();
3740        let templates = result["templates"].as_object().expect("templates object");
3741        let styles = result["templateStyles"].as_array().expect("styles array");
3742
3743        assert_eq!(templates.len(), 1);
3744        assert_eq!(templates["settings-dialog"]["h"], "<div>Settings</div>");
3745        assert_eq!(styles.len(), 1);
3746        let style_html = styles[0].as_str().unwrap();
3747        assert!(
3748            style_html.starts_with(r#"<script type="importmap""#)
3749                && style_html.contains(r#""settings-dialog":"data:text/css,"#),
3750            "templateStyles entry should be an importmap registering settings-dialog: {style_html}"
3751        );
3752        // CSS content is embedded inside the data: URI verbatim — `{`, `}`,
3753        // `\` are not in the percent-encode set.
3754        assert!(
3755            style_html.contains(".dialog{position:fixed}"),
3756            "templateStyles entry should contain the CSS content verbatim: {style_html}"
3757        );
3758        assert!(
3759            index
3760                .template_metadata_cache
3761                .contains_key("settings-dialog"),
3762            "parsed static template metadata should be cached in ProtocolIndex"
3763        );
3764    }
3765
3766    #[test]
3767    fn test_render_component_templates_respects_inventory() {
3768        let mut fragments = HashMap::new();
3769        fragments.insert(
3770            "my-dialog".to_string(),
3771            FragmentList {
3772                fragments: vec![WebUIFragment::raw("<div>Dialog</div>")],
3773            },
3774        );
3775        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3776        let comp = protocol
3777            .components
3778            .entry("my-dialog".to_string())
3779            .or_default();
3780        comp.template_json = r#"{"h":"<div>Dialog</div>"}"#.to_string();
3781        comp.css = ".d{color:red}".to_string();
3782
3783        let mut index = ProtocolIndex::new(&protocol);
3784        // First call: no inventory → should return the component
3785        let result1 =
3786            render_component_templates(&protocol, &["my-dialog"], "", &mut index).unwrap();
3787        let inv = result1["inventory"].as_str().expect("inventory string");
3788        assert_eq!(result1["templates"].as_object().unwrap().len(), 1);
3789
3790        // Second call with inventory → component already loaded, should skip
3791        let result2 =
3792            render_component_templates(&protocol, &["my-dialog"], inv, &mut index).unwrap();
3793        assert_eq!(result2["templates"].as_object().unwrap().len(), 0);
3794        assert_eq!(result2["templateStyles"].as_array().unwrap().len(), 0);
3795    }
3796
3797    #[test]
3798    fn test_render_component_templates_unknown_component_returns_empty() {
3799        let fragments = HashMap::new();
3800        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3801
3802        let mut index = ProtocolIndex::new(&protocol);
3803        let result =
3804            render_component_templates(&protocol, &["nonexistent-widget"], "", &mut index).unwrap();
3805        assert_eq!(result["templates"].as_object().unwrap().len(), 0);
3806        assert_eq!(result["templateStyles"].as_array().unwrap().len(), 0);
3807    }
3808
3809    #[test]
3810    fn test_render_partial_includes_sibling_boundary_templates() {
3811        let mut fragments = HashMap::new();
3812        fragments.insert(
3813            "index.html".to_string(),
3814            FragmentList {
3815                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3816                    path: "/".into(),
3817                    fragment_id: "app-shell".into(),
3818                    exact: false,
3819                    children: vec![
3820                        WebUiFragmentRoute {
3821                            path: "inbox".into(),
3822                            fragment_id: "inbox-page".into(),
3823                            exact: true,
3824                            pending_component: "inbox-loading".into(),
3825                            ..Default::default()
3826                        },
3827                        WebUiFragmentRoute {
3828                            path: "settings".into(),
3829                            fragment_id: "settings-page".into(),
3830                            exact: true,
3831                            pending_component: "settings-loading".into(),
3832                            error_component: "settings-error".into(),
3833                            ..Default::default()
3834                        },
3835                    ],
3836                    ..Default::default()
3837                })],
3838            },
3839        );
3840
3841        for name in [
3842            "app-shell",
3843            "inbox-page",
3844            "inbox-loading",
3845            "settings-page",
3846            "settings-loading",
3847            "settings-error",
3848        ] {
3849            fragments.insert(
3850                name.to_string(),
3851                FragmentList {
3852                    fragments: vec![WebUIFragment::raw(format!("<p>{name}</p>"))],
3853                },
3854            );
3855        }
3856
3857        let mut protocol = WebUIProtocol::new(fragments);
3858        for name in [
3859            "app-shell",
3860            "inbox-page",
3861            "inbox-loading",
3862            "settings-page",
3863            "settings-loading",
3864            "settings-error",
3865        ] {
3866            protocol
3867                .components
3868                .entry(name.to_string())
3869                .or_default()
3870                .template_json = format!(r#"{{"h":"<p>{name}</p>"}}"#);
3871        }
3872
3873        let mut index = ProtocolIndex::new(&protocol);
3874        let partial =
3875            render_partial_metadata(&protocol, "index.html", "/inbox", "", &mut index).unwrap();
3876        let templates = partial["templates"].as_object().unwrap();
3877
3878        assert!(
3879            templates.contains_key("settings-loading"),
3880            "sibling pending template should be preloaded for route transitions: {partial}"
3881        );
3882        assert!(
3883            templates.contains_key("settings-error"),
3884            "sibling error template should be preloaded for failed route transitions: {partial}"
3885        );
3886        let chain = partial["chain"].as_array().unwrap();
3887        assert_eq!(chain.len(), 2);
3888        assert_eq!(chain[0]["component"], "app-shell");
3889        assert_eq!(chain[1]["component"], "inbox-page");
3890    }
3891
3892    // ── resolve_tag_templates tests ──────────────────────────────────
3893
3894    #[test]
3895    fn test_resolve_tag_templates_no_placeholders() {
3896        let tags = vec!["inbox".to_string(), "counts".to_string()];
3897        let params = HashMap::new();
3898        let resolved = resolve_tag_templates(&tags, &params);
3899        assert_eq!(resolved, vec!["inbox", "counts"]);
3900    }
3901
3902    #[test]
3903    fn test_resolve_tag_templates_with_params() {
3904        let tags = vec!["thread:{threadId}".to_string(), "inbox".to_string()];
3905        let mut params = HashMap::new();
3906        params.insert("threadId".to_string(), "42".to_string());
3907        let resolved = resolve_tag_templates(&tags, &params);
3908        assert_eq!(resolved, vec!["thread:42", "inbox"]);
3909    }
3910
3911    #[test]
3912    fn test_resolve_tag_templates_multiple_params() {
3913        let tags = vec!["folder:{folderId}:user:{userId}".to_string()];
3914        let mut params = HashMap::new();
3915        params.insert("folderId".to_string(), "drafts".to_string());
3916        params.insert("userId".to_string(), "abc".to_string());
3917        let resolved = resolve_tag_templates(&tags, &params);
3918        assert_eq!(resolved, vec!["folder:drafts:user:abc"]);
3919    }
3920
3921    #[test]
3922    fn test_resolve_tag_templates_missing_param_left_unresolved() {
3923        let tags = vec!["thread:{threadId}".to_string()];
3924        let params = HashMap::new();
3925        let resolved = resolve_tag_templates(&tags, &params);
3926        assert_eq!(resolved, vec!["thread:{threadId}"]);
3927    }
3928
3929    // ── Chain entry to_json with new fields ───────────────────────
3930
3931    #[test]
3932    fn test_chain_entry_to_json_includes_new_fields() {
3933        let entry = RouteChainEntry {
3934            component: "mail-thread".into(),
3935            path: "email/:threadId".into(),
3936            params: {
3937                let mut p = HashMap::new();
3938                p.insert("threadId".to_string(), "42".to_string());
3939                p
3940            },
3941            exact: true,
3942            allowed_query: String::new(),
3943            keep_alive: false,
3944            cache_tags: vec!["thread:42".to_string()],
3945            invalidates: vec!["inbox".to_string(), "counts".to_string()],
3946            pending_component: "mail-skeleton".into(),
3947            error_component: "error-page".into(),
3948        };
3949        let json = entry.to_json();
3950        assert_eq!(json["pendingComponent"], "mail-skeleton");
3951        assert_eq!(json["errorComponent"], "error-page");
3952        let inv = json["invalidates"].as_array().unwrap();
3953        assert_eq!(inv.len(), 2);
3954        assert_eq!(inv[0], "inbox");
3955        assert_eq!(inv[1], "counts");
3956    }
3957
3958    #[test]
3959    fn test_chain_entry_to_json_omits_empty_new_fields() {
3960        let entry = RouteChainEntry {
3961            component: "home-page".into(),
3962            path: "/".into(),
3963            params: HashMap::new(),
3964            exact: true,
3965            allowed_query: String::new(),
3966            keep_alive: false,
3967            cache_tags: Vec::new(),
3968            invalidates: Vec::new(),
3969            pending_component: String::new(),
3970            error_component: String::new(),
3971        };
3972        let json = entry.to_json();
3973        assert!(json.get("pendingComponent").is_none());
3974        assert!(json.get("errorComponent").is_none());
3975        assert!(json.get("invalidates").is_none());
3976    }
3977
3978    // ── render_partial with cache tags ────────────────────────────
3979
3980    #[test]
3981    fn test_render_partial_includes_resolved_cache_tags() {
3982        let mut fragments = HashMap::new();
3983        fragments.insert(
3984            "index.html".to_string(),
3985            FragmentList {
3986                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3987                    path: "/".into(),
3988                    fragment_id: "app-shell".into(),
3989                    exact: false,
3990                    children: vec![WebUiFragmentRoute {
3991                        path: "email/:threadId".into(),
3992                        fragment_id: "mail-thread".into(),
3993                        exact: true,
3994                        cache_tags: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
3995                        ..Default::default()
3996                    }],
3997                    cache_tags: vec!["folders".to_string()],
3998                    ..Default::default()
3999                })],
4000            },
4001        );
4002        fragments.insert(
4003            "app-shell".to_string(),
4004            FragmentList {
4005                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
4006            },
4007        );
4008        fragments.insert(
4009            "mail-thread".to_string(),
4010            FragmentList {
4011                fragments: vec![WebUIFragment::raw("<p>Thread</p>")],
4012            },
4013        );
4014        let protocol = WebUIProtocol::new(fragments);
4015
4016        let mut index = ProtocolIndex::new(&protocol);
4017        let partial =
4018            render_partial_metadata(&protocol, "index.html", "/email/42", "", &mut index).unwrap();
4019        let tags = partial["cacheTags"].as_array().unwrap();
4020        let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
4021        assert!(
4022            tag_strings.contains(&"folders"),
4023            "should contain parent tags"
4024        );
4025        assert!(
4026            tag_strings.contains(&"thread:42"),
4027            "should resolve threadId param to 42"
4028        );
4029        assert!(tag_strings.contains(&"inbox"), "should contain static tags");
4030    }
4031
4032    // ── render_action_response tests ─────────────────────────────
4033
4034    #[test]
4035    fn test_render_action_response_returns_resolved_invalidation_tags() {
4036        let mut fragments = HashMap::new();
4037        fragments.insert(
4038            "index.html".to_string(),
4039            FragmentList {
4040                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
4041                    path: "/".into(),
4042                    fragment_id: "app-shell".into(),
4043                    exact: false,
4044                    children: vec![WebUiFragmentRoute {
4045                        path: "compose".into(),
4046                        fragment_id: "compose-page".into(),
4047                        exact: true,
4048                        invalidates: vec![
4049                            "inbox".to_string(),
4050                            "sent".to_string(),
4051                            "counts".to_string(),
4052                        ],
4053                        ..Default::default()
4054                    }],
4055                    ..Default::default()
4056                })],
4057            },
4058        );
4059        fragments.insert(
4060            "app-shell".to_string(),
4061            FragmentList {
4062                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
4063            },
4064        );
4065        fragments.insert(
4066            "compose-page".to_string(),
4067            FragmentList {
4068                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
4069            },
4070        );
4071        let protocol = WebUIProtocol::new(fragments);
4072
4073        let mut index = ProtocolIndex::new(&protocol);
4074        let result = render_action_response(
4075            &protocol,
4076            serde_json::json!({"ok": true}),
4077            "index.html",
4078            "/compose",
4079            &mut index,
4080        );
4081
4082        let tags = result["invalidateTags"].as_array().unwrap();
4083        let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
4084        assert_eq!(tag_strings, vec!["inbox", "sent", "counts"]);
4085        assert_eq!(result["state"]["ok"], true);
4086    }
4087
4088    #[test]
4089    fn test_render_action_response_resolves_param_placeholders() {
4090        let mut fragments = HashMap::new();
4091        fragments.insert(
4092            "index.html".to_string(),
4093            FragmentList {
4094                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
4095                    path: "/".into(),
4096                    fragment_id: "app-shell".into(),
4097                    exact: false,
4098                    children: vec![WebUiFragmentRoute {
4099                        path: "email/:threadId/reply".into(),
4100                        fragment_id: "reply-page".into(),
4101                        exact: true,
4102                        invalidates: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
4103                        ..Default::default()
4104                    }],
4105                    ..Default::default()
4106                })],
4107            },
4108        );
4109        fragments.insert(
4110            "app-shell".to_string(),
4111            FragmentList {
4112                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
4113            },
4114        );
4115        fragments.insert(
4116            "reply-page".to_string(),
4117            FragmentList {
4118                fragments: vec![WebUIFragment::raw("<p>Reply</p>")],
4119            },
4120        );
4121        let protocol = WebUIProtocol::new(fragments);
4122
4123        let mut index = ProtocolIndex::new(&protocol);
4124        let result = render_action_response(
4125            &protocol,
4126            serde_json::json!({}),
4127            "index.html",
4128            "/email/42/reply",
4129            &mut index,
4130        );
4131
4132        let tags = result["invalidateTags"].as_array().unwrap();
4133        let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
4134        assert!(tag_strings.contains(&"thread:42"));
4135        assert!(tag_strings.contains(&"inbox"));
4136    }
4137}