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