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