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