Skip to main content

webui_handler/
route_handler.rs

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