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