Skip to main content

webui_handler/
route_handler.rs

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