Skip to main content

webui_handler/
route_handler.rs

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