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