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};
12use route_matcher::CompiledRouteCache;
13use serde_json::Value;
14use std::collections::{HashMap, HashSet};
15use webui_protocol::{web_ui_fragment::Fragment, WebUIFragmentRoute, WebUIProtocol};
16
17// ── Protocol Index ──────────────────────────────────────────────────────
18
19/// Pre-computed index for a protocol, caching expensive lookups.
20///
21/// Both the component bit-index map and the compiled route template cache
22/// are deterministic for a given protocol. Building them once avoids
23/// redundant work on every request.
24pub struct ProtocolIndex {
25    /// Component name → bit index for inventory tracking.
26    pub component_index: HashMap<String, u32>,
27    /// Compiled route template patterns (lazily populated on first match).
28    pub route_cache: CompiledRouteCache,
29    /// Parsed WebUI template metadata keyed by component tag.
30    template_metadata_cache: HashMap<String, Value>,
31}
32
33struct ComponentAssets {
34    styles: Vec<Value>,
35    templates: serde_json::Map<String, Value>,
36    functions: serde_json::Map<String, Value>,
37}
38
39impl ProtocolIndex {
40    /// Build a protocol index from a compiled protocol.
41    #[must_use]
42    pub fn new(protocol: &WebUIProtocol) -> Self {
43        Self {
44            component_index: build_component_index(protocol),
45            route_cache: CompiledRouteCache::new(),
46            template_metadata_cache: HashMap::new(),
47        }
48    }
49}
50
51// ── Component Inventory ─────────────────────────────────────────────────
52
53/// Build a deterministic component-name → bit-index map from the protocol.
54///
55/// Derives names from fragment keys (hyphenated = custom element) since that
56/// is the source of truth regardless of whether a plugin populated
57/// `protocol.components`. Components are sorted alphabetically; index =
58/// position in that order.
59pub fn build_component_index(protocol: &WebUIProtocol) -> HashMap<String, u32> {
60    let mut sorted: Vec<&String> = protocol
61        .fragments
62        .keys()
63        .filter(|key| key.contains('-'))
64        .collect();
65    sorted.sort_unstable();
66    let mut index = HashMap::with_capacity(sorted.len());
67    for (i, name) in sorted.into_iter().enumerate() {
68        // Index count bounded by component registry size, well within u32 range
69        #[allow(clippy::cast_possible_truncation)]
70        let idx = i as u32;
71        index.insert(name.clone(), idx);
72    }
73    index
74}
75
76/// Check if a component's bit is set in the inventory bitfield.
77fn has_component(inventory: &[u8], index: u32) -> bool {
78    let byte_idx = (index / 8) as usize;
79    let bit_idx = index % 8;
80    byte_idx < inventory.len() && inventory[byte_idx] & (1 << bit_idx) != 0
81}
82
83/// Set a component's bit in the inventory bitfield.
84fn set_component(inventory: &mut Vec<u8>, index: u32) {
85    let byte_idx = (index / 8) as usize;
86    let bit_idx = index % 8;
87    if byte_idx >= inventory.len() {
88        inventory.resize(byte_idx + 1, 0);
89    }
90    inventory[byte_idx] |= 1 << bit_idx;
91}
92
93/// Parse a hex-encoded inventory string into bytes.
94///
95/// Returns an error if the hex string has odd length or contains non-hex characters.
96pub fn parse_inventory(hex: &str) -> Result<Vec<u8>, HandlerError> {
97    if hex.is_empty() {
98        return Ok(Vec::new());
99    }
100    if !hex.len().is_multiple_of(2) {
101        return Err(HandlerError::Protocol(
102            webui_protocol::ProtocolError::Validation(format!(
103                "inventory hex has odd length: {}",
104                hex.len()
105            )),
106        ));
107    }
108    let mut bytes = Vec::with_capacity(hex.len() / 2);
109    let mut chars = hex.bytes();
110    while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
111        let h = route_matcher::hex_val(hi).ok_or_else(|| {
112            HandlerError::Protocol(webui_protocol::ProtocolError::Validation(format!(
113                "invalid hex digit in inventory: {:#04x}",
114                hi
115            )))
116        })?;
117        let l = route_matcher::hex_val(lo).ok_or_else(|| {
118            HandlerError::Protocol(webui_protocol::ProtocolError::Validation(format!(
119                "invalid hex digit in inventory: {:#04x}",
120                lo
121            )))
122        })?;
123        bytes.push((h << 4) | l);
124    }
125    Ok(bytes)
126}
127
128/// Encode an inventory bitfield as a hex string.
129pub fn encode_inventory(inv: &[u8]) -> String {
130    const HEX: &[u8; 16] = b"0123456789abcdef";
131    let mut encoded = String::with_capacity(inv.len() * 2);
132    for byte in inv {
133        encoded.push(char::from(HEX[usize::from(byte >> 4)]));
134        encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
135    }
136    encoded
137}
138
139/// Encode the inventory bitfield for a known set of rendered components.
140pub(crate) fn encode_component_inventory(
141    component_names: &HashSet<String>,
142    index: &HashMap<String, u32>,
143) -> String {
144    let mut inventory = Vec::new();
145    for name in component_names {
146        if let Some(&idx) = index.get(name.as_str()) {
147            set_component(&mut inventory, idx);
148        }
149    }
150    encode_inventory(&inventory)
151}
152
153/// Walk the protocol fragment graph from `entry_id` and return the names of
154/// all components the route needs that are NOT in the client's inventory.
155///
156/// Returns `(needed_names, updated_inventory_hex)`.
157pub fn get_needed_components(
158    protocol: &WebUIProtocol,
159    entry_id: &str,
160    inventory_hex: &str,
161    component_index: &HashMap<String, u32>,
162) -> Result<(Vec<String>, String), HandlerError> {
163    let component_names = collect_inventoryable_components(
164        protocol,
165        entry_id,
166        None,
167        true,
168        &mut CompiledRouteCache::new(),
169    );
170    filter_needed_components(&component_names, inventory_hex, component_index)
171}
172
173/// Walk the protocol from the persistent `entry_id` and return the component
174/// templates needed for the current `request_path`.
175///
176/// Returns `(needed_names, updated_inventory_hex)`.
177pub fn get_needed_components_for_request(
178    protocol: &WebUIProtocol,
179    entry_id: &str,
180    request_path: &str,
181    inventory_hex: &str,
182    component_index: &HashMap<String, u32>,
183) -> Result<(Vec<String>, String), HandlerError> {
184    let component_names = collect_inventoryable_components(
185        protocol,
186        entry_id,
187        Some(request_path),
188        false,
189        &mut CompiledRouteCache::new(),
190    );
191    filter_needed_components(&component_names, inventory_hex, component_index)
192}
193
194/// Collect all route-reachable inventoryable components for the request path.
195pub(crate) fn collect_reachable_components_for_request(
196    protocol: &WebUIProtocol,
197    entry_id: &str,
198    request_path: &str,
199    cache: &mut CompiledRouteCache,
200) -> HashSet<String> {
201    // Callers here need set semantics (`.contains`, plugin `&HashSet` API);
202    // discovery order is irrelevant for reachable-template emission.
203    collect_inventoryable_components(protocol, entry_id, Some(request_path), false, cache)
204        .into_iter()
205        .collect()
206}
207
208/// Filter components against the client's inventory bitfield using sequential indices.
209/// Zero collisions — each component has a unique bit.
210///
211/// Expects `component_names` to already be unique. Input order is preserved
212/// in the returned `needed` vector (no alphabetical re-sort), so downstream
213/// `<head>` CSS `<link>` emission follows document/traversal order. The
214/// updated inventory hex is order-independent (bits are keyed by `index`).
215///
216/// Returns the missing component names and the updated inventory hex string.
217pub fn filter_needed_components(
218    component_names: &[String],
219    inventory_hex: &str,
220    index: &HashMap<String, u32>,
221) -> Result<(Vec<String>, String), HandlerError> {
222    let client_inv = parse_inventory(inventory_hex)?;
223    let mut updated_inv = client_inv.clone();
224
225    let mut needed = Vec::with_capacity(component_names.len());
226    for name in component_names {
227        if let Some(&idx) = index.get(name.as_str()) {
228            if !has_component(&client_inv, idx) {
229                needed.push(name.clone());
230            }
231            set_component(&mut updated_inv, idx);
232        } else {
233            // Component exists in the fragment graph but has no index entry
234            // (no protocol.components record). It can't be tracked in the
235            // bitfield, so we must always send it.
236            needed.push(name.clone());
237        }
238    }
239
240    Ok((needed, encode_inventory(&updated_inv)))
241}
242
243fn has_template_payload(component: &webui_protocol::ComponentData) -> bool {
244    !component.template_json.is_empty() || !component.template.is_empty()
245}
246
247#[derive(Debug)]
248struct QueuedFragment {
249    id: String,
250    inventoryable: bool,
251    /// Base path for resolving relative route paths at this level.
252    route_base: String,
253}
254
255/// Shared context for route child walkers, bundling common parameters
256/// to stay within the 5-argument clippy limit.
257struct ChildWalkCtx<'a> {
258    request_path: &'a str,
259    protocol: &'a WebUIProtocol,
260    cache: &'a mut CompiledRouteCache,
261}
262
263/// Walk the fragment graph from `entry_id` and collect all inventoryable
264/// component names in document/traversal (first-discovery) order.
265///
266/// Uses an iterative stack-based traversal. When `request_path` is provided,
267/// the walk follows only the best-matching route at each nesting level (route-aware).
268/// Without a request path, all route branches are followed conservatively.
269///
270/// Components are marked `inventoryable` when they have a corresponding entry
271/// in `protocol.components` with a non-empty template payload — these are the
272/// components whose client metadata the browser may need during navigation.
273fn collect_inventoryable_components(
274    protocol: &WebUIProtocol,
275    entry_id: &str,
276    request_path: Option<&str>,
277    root_inventoryable: bool,
278    cache: &mut CompiledRouteCache,
279) -> Vec<String> {
280    let mut visited_fragments = HashSet::new();
281    // Preserve first-discovery (document/traversal) order so Link-strategy
282    // CSS `<link>` tags are emitted in source order, not alphabetically.
283    // `seen_components` dedups; `component_ids` keeps order (a plain
284    // `HashSet` would lose it).
285    let mut seen_components = HashSet::new();
286    let mut component_ids: Vec<String> = Vec::new();
287    let mut stack = vec![QueuedFragment {
288        id: entry_id.to_string(),
289        inventoryable: root_inventoryable,
290        route_base: "/".to_string(),
291    }];
292
293    while let Some(queued) = stack.pop() {
294        if queued.id.is_empty() {
295            continue;
296        }
297
298        if queued.inventoryable && seen_components.insert(queued.id.clone()) {
299            component_ids.push(queued.id.clone());
300        }
301
302        if !visited_fragments.insert(queued.id.clone()) {
303            continue;
304        }
305
306        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
307            continue;
308        };
309
310        let matched_route = request_path.and_then(|path| {
311            route_renderer::find_best_route_match(
312                &frag_list.fragments,
313                path,
314                &queued.route_base,
315                cache,
316            )
317        });
318
319        // Reversed iteration so this LIFO stack pops children in document
320        // order, giving deterministic, source-ordered component discovery.
321        for frag in frag_list.fragments.iter().rev() {
322            match frag.fragment.as_ref() {
323                Some(Fragment::Component(component)) => {
324                    stack.push(QueuedFragment {
325                        id: component.fragment_id.clone(),
326                        inventoryable: true,
327                        route_base: queued.route_base.clone(),
328                    });
329                }
330                Some(Fragment::ForLoop(for_loop)) => {
331                    stack.push(QueuedFragment {
332                        id: for_loop.fragment_id.clone(),
333                        inventoryable: false,
334                        route_base: queued.route_base.clone(),
335                    });
336                }
337                Some(Fragment::IfCond(if_cond)) => {
338                    stack.push(QueuedFragment {
339                        id: if_cond.fragment_id.clone(),
340                        inventoryable: false,
341                        route_base: queued.route_base.clone(),
342                    });
343                }
344                Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
345                    stack.push(QueuedFragment {
346                        id: attr.template.clone(),
347                        inventoryable: false,
348                        route_base: queued.route_base.clone(),
349                    });
350                }
351                Some(Fragment::Route(route_frag)) => {
352                    let is_selected = matched_route
353                        .as_ref()
354                        .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
355                    if is_selected && !route_frag.fragment_id.is_empty() {
356                        // Compute new route base from consumed segments
357                        let child_route_base = if let Some((_, ref rm)) = matched_route {
358                            if let Some(path) = request_path {
359                                route_matcher::compute_route_base(path, rm.consumed_segments)
360                            } else {
361                                queued.route_base.clone()
362                            }
363                        } else {
364                            queued.route_base.clone()
365                        };
366
367                        stack.push(QueuedFragment {
368                            id: route_frag.fragment_id.clone(),
369                            inventoryable: protocol
370                                .components
371                                .get(&route_frag.fragment_id)
372                                .is_some_and(has_template_payload),
373                            route_base: child_route_base.clone(),
374                        });
375
376                        // Inventory pending/error components for the entire
377                        // route subtree, not just the matched chain. Pending UI
378                        // renders while a (possibly unmatched) sibling route's
379                        // partial is in flight, and error UI renders if that
380                        // fetch fails, so the client must already hold these
381                        // templates before — or without — a successful fetch for
382                        // the target route.
383                        collect_route_boundary_components(
384                            std::slice::from_ref(route_frag),
385                            &child_route_base,
386                            protocol,
387                            &mut stack,
388                        );
389
390                        // Walk nested child routes to find the next matched level.
391                        // This mirrors the handler's outlet rendering: match children
392                        // against the request path and follow the matched chain.
393                        if !route_frag.children.is_empty() {
394                            if let Some(path) = request_path {
395                                walk_route_children(
396                                    &route_frag.children,
397                                    &child_route_base,
398                                    &mut stack,
399                                    &mut ChildWalkCtx {
400                                        request_path: path,
401                                        protocol,
402                                        cache,
403                                    },
404                                );
405                            }
406                        }
407                    }
408                }
409                _ => {}
410            }
411        }
412    }
413
414    component_ids
415}
416
417/// Select the best-matching child route among siblings by specificity.
418///
419/// Returns the index and match result of the highest-specificity match,
420/// preserving first-match-wins for equal specificity (declaration order).
421fn select_best_child_route(
422    children: &[WebUIFragmentRoute],
423    request_path: &str,
424    route_base: &str,
425    cache: &mut CompiledRouteCache,
426) -> Option<(usize, route_matcher::RouteMatch)> {
427    let request_segments = route_matcher::split_request_path(request_path);
428    let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
429    for (idx, child) in children.iter().enumerate() {
430        let resolved = route_matcher::resolve_route_path_cow(&child.path, route_base);
431        if let Some(m) = route_matcher::match_route_cached_with_segments(
432            cache,
433            resolved.as_ref(),
434            &request_segments,
435            child.exact,
436        ) {
437            let is_better = best
438                .as_ref()
439                .is_none_or(|(_, prev)| m.specificity > prev.specificity);
440            if is_better {
441                best = Some((idx, m));
442            }
443        }
444    }
445    best
446}
447
448/// Walk nested route children to find matched routes and add their
449/// components to the inventory stack. Mirrors the handler's outlet rendering.
450fn walk_route_children(
451    children: &[WebUIFragmentRoute],
452    route_base: &str,
453    stack: &mut Vec<QueuedFragment>,
454    ctx: &mut ChildWalkCtx<'_>,
455) {
456    let mut current = children;
457    let mut base = route_base.to_string();
458
459    loop {
460        let Some((idx, ref rm)) =
461            select_best_child_route(current, ctx.request_path, &base, ctx.cache)
462        else {
463            break;
464        };
465        let matched = &current[idx];
466        if matched.fragment_id.is_empty() {
467            break;
468        }
469
470        let child_base = route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
471
472        stack.push(QueuedFragment {
473            id: matched.fragment_id.clone(),
474            inventoryable: ctx
475                .protocol
476                .components
477                .get(&matched.fragment_id)
478                .is_some_and(has_template_payload),
479            route_base: child_base.clone(),
480        });
481
482        if !matched.pending_component.is_empty() {
483            stack.push(QueuedFragment {
484                id: matched.pending_component.clone(),
485                inventoryable: ctx
486                    .protocol
487                    .components
488                    .get(&matched.pending_component)
489                    .is_some_and(has_template_payload),
490                route_base: child_base.clone(),
491            });
492        }
493        if !matched.error_component.is_empty() {
494            stack.push(QueuedFragment {
495                id: matched.error_component.clone(),
496                inventoryable: ctx
497                    .protocol
498                    .components
499                    .get(&matched.error_component)
500                    .is_some_and(has_template_payload),
501                route_base: child_base.clone(),
502            });
503        }
504
505        if matched.children.is_empty() {
506            break;
507        }
508        current = &matched.children;
509        base = child_base;
510    }
511}
512
513/// Inventory the pending/error boundary components for an entire route subtree.
514///
515/// Unlike [`walk_route_children`], which follows only the best-matching chain,
516/// this collects boundary components for *every* route reachable from `routes`.
517/// Pending UI renders while a route's partial fetch is in flight and error UI
518/// renders if that fetch fails, so the client must already hold these templates
519/// for any navigable sibling — not only the currently active route. The walk is
520/// iterative because the framework forbids recursion in core paths.
521fn collect_route_boundary_components(
522    routes: &[WebUIFragmentRoute],
523    route_base: &str,
524    protocol: &WebUIProtocol,
525    stack: &mut Vec<QueuedFragment>,
526) {
527    let mut remaining: Vec<&WebUIFragmentRoute> = routes.iter().collect();
528    while let Some(route) = remaining.pop() {
529        for component in [&route.pending_component, &route.error_component] {
530            if component.is_empty() {
531                continue;
532            }
533            stack.push(QueuedFragment {
534                id: component.clone(),
535                inventoryable: protocol
536                    .components
537                    .get(component)
538                    .is_some_and(has_template_payload),
539                route_base: route_base.to_string(),
540            });
541        }
542        remaining.extend(route.children.iter());
543    }
544}
545
546/// Walk the fragment graph following matched routes and collect all route
547/// parameters from every level of the active route chain.
548///
549/// This is used by the dev server to inject nested route params into state.
550pub fn collect_nested_route_params(
551    protocol: &WebUIProtocol,
552    entry_id: &str,
553    request_path: &str,
554    cache: &mut CompiledRouteCache,
555) -> HashMap<String, String> {
556    let mut all_params = HashMap::new();
557    let mut visited_fragments = HashSet::new();
558    let mut stack = vec![QueuedFragment {
559        id: entry_id.to_string(),
560        inventoryable: false,
561        route_base: "/".to_string(),
562    }];
563
564    while let Some(queued) = stack.pop() {
565        if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
566            continue;
567        }
568
569        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
570            continue;
571        };
572
573        let matched_route = route_renderer::find_best_route_match(
574            &frag_list.fragments,
575            request_path,
576            &queued.route_base,
577            cache,
578        );
579
580        for frag in &frag_list.fragments {
581            match frag.fragment.as_ref() {
582                Some(Fragment::Component(component)) => {
583                    stack.push(QueuedFragment {
584                        id: component.fragment_id.clone(),
585                        inventoryable: false,
586                        route_base: queued.route_base.clone(),
587                    });
588                }
589                Some(Fragment::Route(route_frag)) => {
590                    let is_selected = matched_route
591                        .as_ref()
592                        .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
593                    if is_selected && !route_frag.fragment_id.is_empty() {
594                        if let Some((_, ref rm)) = matched_route {
595                            // Collect params from this route level
596                            all_params.extend(rm.params.clone());
597
598                            let child_route_base = route_matcher::compute_route_base(
599                                request_path,
600                                rm.consumed_segments,
601                            );
602
603                            stack.push(QueuedFragment {
604                                id: route_frag.fragment_id.clone(),
605                                inventoryable: false,
606                                route_base: child_route_base.clone(),
607                            });
608
609                            // Walk nested children to collect params from deeper levels
610                            collect_params_from_children(
611                                &route_frag.children,
612                                request_path,
613                                &child_route_base,
614                                &mut all_params,
615                                cache,
616                            );
617                        }
618                    }
619                }
620                _ => {}
621            }
622        }
623    }
624
625    all_params
626}
627
628/// Iteratively collect route params from nested route children.
629fn collect_params_from_children(
630    children: &[WebUIFragmentRoute],
631    request_path: &str,
632    route_base: &str,
633    all_params: &mut HashMap<String, String>,
634    cache: &mut CompiledRouteCache,
635) {
636    let mut current = children;
637    let mut base = route_base.to_string();
638
639    loop {
640        let Some((idx, rm)) = select_best_child_route(current, request_path, &base, cache) else {
641            break;
642        };
643        all_params.extend(rm.params);
644        let matched = &current[idx];
645        if matched.children.is_empty() {
646            break;
647        }
648        let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
649        current = &matched.children;
650        base = child_base;
651    }
652}
653
654// ── Partial Response ────────────────────────────────────────────────
655
656/// Resolve `{param}` placeholders in a list of tag templates.
657///
658/// Replaces `{paramName}` with the actual value from `params`.
659/// Tags without placeholders are returned as-is. Missing params
660/// result in the placeholder being left unresolved (defensive).
661fn resolve_tag_templates(templates: &[String], params: &HashMap<String, String>) -> Vec<String> {
662    let mut resolved = Vec::with_capacity(templates.len());
663    for template in templates {
664        if !template.contains('{') {
665            resolved.push(template.clone());
666            continue;
667        }
668        let mut result = String::with_capacity(template.len());
669        let mut rest = template.as_str();
670        while let Some(open) = rest.find('{') {
671            result.push_str(&rest[..open]);
672            rest = &rest[open + 1..];
673            if let Some(close) = rest.find('}') {
674                let name = &rest[..close];
675                if let Some(value) = params.get(name) {
676                    result.push_str(value);
677                } else {
678                    result.push('{');
679                    result.push_str(name);
680                    result.push('}');
681                }
682                rest = &rest[close + 1..];
683            } else {
684                result.push('{');
685            }
686        }
687        result.push_str(rest);
688        resolved.push(result);
689    }
690    resolved
691}
692
693/// Single-pass graph walk that collects both inventoryable component names
694/// and the matched route chain. Eliminates the duplicate graph traversal
695/// that previously existed in `render_partial`.
696fn collect_inventory_and_chain(
697    protocol: &WebUIProtocol,
698    entry_id: &str,
699    request_path: &str,
700    index: &mut ProtocolIndex,
701) -> (Vec<String>, Vec<RouteChainEntry>) {
702    let mut visited_fragments = HashSet::new();
703    // Deduped ordered collection: `filter_needed_components` now takes a
704    // slice of unique names. Order is irrelevant for this consumer
705    // (`render_partial` re-sorts in `collect_component_assets`), but the
706    // list must stay duplicate-free.
707    let mut seen_components = HashSet::new();
708    let mut component_ids: Vec<String> = Vec::new();
709    let mut chain = Vec::new();
710    let mut stack = vec![QueuedFragment {
711        id: entry_id.to_string(),
712        inventoryable: false,
713        route_base: "/".to_string(),
714    }];
715
716    while let Some(queued) = stack.pop() {
717        if queued.id.is_empty() {
718            continue;
719        }
720
721        if queued.inventoryable && seen_components.insert(queued.id.clone()) {
722            component_ids.push(queued.id.clone());
723        }
724
725        if !visited_fragments.insert(queued.id.clone()) {
726            continue;
727        }
728
729        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
730            continue;
731        };
732
733        let matched_route = route_renderer::find_best_route_match(
734            &frag_list.fragments,
735            request_path,
736            &queued.route_base,
737            &mut index.route_cache,
738        );
739
740        for frag in &frag_list.fragments {
741            match frag.fragment.as_ref() {
742                Some(Fragment::Component(component)) => {
743                    // Inventory: components are inventoryable
744                    stack.push(QueuedFragment {
745                        id: component.fragment_id.clone(),
746                        inventoryable: true,
747                        route_base: queued.route_base.clone(),
748                    });
749                }
750                Some(Fragment::ForLoop(for_loop)) => {
751                    // Inventory: follow control-flow edges conservatively
752                    stack.push(QueuedFragment {
753                        id: for_loop.fragment_id.clone(),
754                        inventoryable: false,
755                        route_base: queued.route_base.clone(),
756                    });
757                }
758                Some(Fragment::IfCond(if_cond)) => {
759                    stack.push(QueuedFragment {
760                        id: if_cond.fragment_id.clone(),
761                        inventoryable: false,
762                        route_base: queued.route_base.clone(),
763                    });
764                }
765                Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
766                    stack.push(QueuedFragment {
767                        id: attr.template.clone(),
768                        inventoryable: false,
769                        route_base: queued.route_base.clone(),
770                    });
771                }
772                Some(Fragment::Route(route_frag)) => {
773                    let is_selected = matched_route
774                        .as_ref()
775                        .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
776                    if is_selected && !route_frag.fragment_id.is_empty() {
777                        if let Some((_, ref rm)) = matched_route {
778                            // Chain: record matched route entry
779                            chain.push(RouteChainEntry {
780                                component: route_frag.fragment_id.clone(),
781                                path: route_frag.path.clone(),
782                                params: rm.params.clone(),
783                                exact: route_frag.exact,
784                                allowed_query: route_frag.allowed_query.clone(),
785                                keep_alive: route_frag.keep_alive,
786                                cache_tags: route_frag.cache_tags.clone(),
787                                invalidates: route_frag.invalidates.clone(),
788                                pending_component: route_frag.pending_component.clone(),
789                                error_component: route_frag.error_component.clone(),
790                            });
791
792                            let child_route_base = route_matcher::compute_route_base(
793                                request_path,
794                                rm.consumed_segments,
795                            );
796
797                            // Inventory: follow matched route component
798                            let is_inventoryable = protocol
799                                .components
800                                .get(&route_frag.fragment_id)
801                                .is_some_and(has_template_payload);
802                            stack.push(QueuedFragment {
803                                id: route_frag.fragment_id.clone(),
804                                inventoryable: is_inventoryable,
805                                route_base: child_route_base.clone(),
806                            });
807
808                            collect_route_boundary_components(
809                                std::slice::from_ref(route_frag),
810                                &child_route_base,
811                                protocol,
812                                &mut stack,
813                            );
814
815                            // Both: walk nested child routes
816                            if !route_frag.children.is_empty() {
817                                walk_children_for_inventory_and_chain(
818                                    &route_frag.children,
819                                    &child_route_base,
820                                    &mut stack,
821                                    &mut chain,
822                                    &mut ChildWalkCtx {
823                                        request_path,
824                                        protocol,
825                                        cache: &mut index.route_cache,
826                                    },
827                                );
828                            }
829                        }
830                    }
831                }
832                _ => {}
833            }
834        }
835    }
836
837    (component_ids, chain)
838}
839
840/// Walk nested route children, collecting both inventory and chain entries.
841fn walk_children_for_inventory_and_chain(
842    children: &[WebUIFragmentRoute],
843    route_base: &str,
844    stack: &mut Vec<QueuedFragment>,
845    chain: &mut Vec<RouteChainEntry>,
846    ctx: &mut ChildWalkCtx<'_>,
847) {
848    let mut current = children;
849    let mut base = route_base.to_string();
850
851    loop {
852        let Some((idx, ref rm)) =
853            select_best_child_route(current, ctx.request_path, &base, ctx.cache)
854        else {
855            break;
856        };
857        let matched = &current[idx];
858        if matched.fragment_id.is_empty() {
859            break;
860        }
861
862        let child_base = route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
863
864        chain.push(RouteChainEntry {
865            component: matched.fragment_id.clone(),
866            path: matched.path.clone(),
867            params: rm.params.clone(),
868            exact: matched.exact,
869            allowed_query: matched.allowed_query.clone(),
870            keep_alive: matched.keep_alive,
871            cache_tags: matched.cache_tags.clone(),
872            invalidates: matched.invalidates.clone(),
873            pending_component: matched.pending_component.clone(),
874            error_component: matched.error_component.clone(),
875        });
876
877        stack.push(QueuedFragment {
878            id: matched.fragment_id.clone(),
879            inventoryable: ctx
880                .protocol
881                .components
882                .get(&matched.fragment_id)
883                .is_some_and(has_template_payload),
884            route_base: child_base.clone(),
885        });
886
887        if matched.children.is_empty() {
888            break;
889        }
890        current = &matched.children;
891        base = child_base;
892    }
893}
894
895/// Produce a JSON partial response for client-side navigation.
896///
897/// Returns the matched route chain, templates, inventory, and cache tags.
898/// **State is not included** — the caller is responsible for adding
899/// application state to the response (e.g. as a top-level `"state"` field
900/// for non-streaming, or as NDJSON Chunk 2 for streaming).
901///
902/// Returns a `serde_json::Value` object with fields:
903/// - `templateStyles`: module CSS definition tags for inventory-new components (empty for Link/Style)
904/// - `templates`: client template metadata keyed by component tag (inventory-filtered)
905/// - `templateFunctions`: component-local condition closure arrays keyed by component tag
906/// - `inventory`: updated hex bitmask
907/// - `path`: the request path
908/// - `chain`: matched route chain array
909/// - `cacheTags`: resolved cache tags from the full route chain (union of all levels)
910pub fn render_partial(
911    protocol: &WebUIProtocol,
912    entry_id: &str,
913    request_path: &str,
914    inventory_hex: &str,
915    index: &mut ProtocolIndex,
916) -> Result<Value, HandlerError> {
917    // Single-pass walk: collect both inventory components and route chain.
918    let (component_ids, mut chain) =
919        collect_inventory_and_chain(protocol, entry_id, request_path, index);
920
921    let (needed_names, updated_inv) =
922        filter_needed_components(&component_ids, inventory_hex, &index.component_index)?;
923
924    // Resolve cache tags and invalidation templates with accumulated params.
925    let mut accumulated_params: HashMap<String, String> = HashMap::new();
926    let mut all_resolved_tags: Vec<String> = Vec::new();
927
928    for entry in &mut chain {
929        for (k, v) in &entry.params {
930            accumulated_params.insert(k.clone(), v.clone());
931        }
932        entry.cache_tags = resolve_tag_templates(&entry.cache_tags, &accumulated_params);
933        entry.invalidates = resolve_tag_templates(&entry.invalidates, &accumulated_params);
934        all_resolved_tags.extend(entry.cache_tags.iter().cloned());
935    }
936
937    let tag_refs: Vec<&str> = needed_names.iter().map(|s| s.as_str()).collect();
938    let assets = collect_component_assets(protocol, &tag_refs, index)?;
939
940    let chain_array = Value::Array(chain.iter().map(RouteChainEntry::to_json).collect());
941
942    let mut result = serde_json::Map::with_capacity(7);
943    result.insert("templateStyles".into(), Value::Array(assets.styles));
944    result.insert("templates".into(), Value::Object(assets.templates));
945    result.insert("templateFunctions".into(), Value::Object(assets.functions));
946    result.insert("inventory".into(), Value::String(updated_inv));
947    result.insert("path".into(), Value::String(request_path.to_string()));
948    result.insert("chain".into(), chain_array);
949    if !all_resolved_tags.is_empty() {
950        let mut seen = HashSet::new();
951        let deduped: Vec<Value> = all_resolved_tags
952            .into_iter()
953            .filter(|t| seen.insert(t.clone()))
954            .map(Value::String)
955            .collect();
956        result.insert("cacheTags".into(), Value::Array(deduped));
957    }
958    Ok(Value::Object(result))
959}
960
961/// Produce a JSON response for a POST mutation action.
962///
963/// Walks the matched route chain, resolves `invalidates` tag templates
964/// with actual param values, and returns them alongside the application
965/// state. Host servers call this for `POST` requests to route paths.
966///
967/// Returns a `serde_json::Value` object with fields:
968/// - `state`: the application state passed through
969/// - `invalidateTags`: resolved invalidation tags from the matched leaf route
970/// - `path`: the request path
971pub fn render_action_response(
972    protocol: &WebUIProtocol,
973    state: Value,
974    entry_id: &str,
975    request_path: &str,
976    index: &mut ProtocolIndex,
977) -> Value {
978    let chain = collect_route_chain(protocol, entry_id, request_path, &mut index.route_cache);
979
980    // Accumulate params across the chain for tag resolution
981    let mut accumulated_params: HashMap<String, String> = HashMap::new();
982    let mut all_invalidates: Vec<String> = Vec::new();
983
984    for entry in &chain {
985        for (k, v) in &entry.params {
986            accumulated_params.insert(k.clone(), v.clone());
987        }
988        let resolved = resolve_tag_templates(&entry.invalidates, &accumulated_params);
989        all_invalidates.extend(resolved);
990    }
991
992    // Deduplicate while preserving order
993    let mut seen = HashSet::new();
994    let deduped: Vec<Value> = all_invalidates
995        .into_iter()
996        .filter(|t| seen.insert(t.clone()))
997        .map(Value::String)
998        .collect();
999
1000    let mut result = serde_json::Map::with_capacity(3);
1001    result.insert("state".into(), state);
1002    result.insert("invalidateTags".into(), Value::Array(deduped));
1003    result.insert("path".into(), Value::String(request_path.to_string()));
1004    Value::Object(result)
1005}
1006
1007/// Return compiled templates and CSS for specific components by tag name.
1008///
1009/// This supports on-demand loading of components that aren't part of the
1010/// route tree (e.g., dialogs, popovers) — the client calls this before
1011/// creating the element so templates + styles are registered without FOUC.
1012///
1013/// Uses the same inventory bitfield as partial navigation to avoid sending
1014/// templates the client already has.
1015pub fn render_component_templates(
1016    protocol: &WebUIProtocol,
1017    component_tags: &[&str],
1018    inventory_hex: &str,
1019    index: &mut ProtocolIndex,
1020) -> Result<Value, HandlerError> {
1021    // Deduplicate preserving caller order; filter expects unique names.
1022    let mut seen: HashSet<&str> = HashSet::new();
1023    let requested: Vec<String> = component_tags
1024        .iter()
1025        .filter(|s| seen.insert(**s))
1026        .map(|s| (*s).to_string())
1027        .collect();
1028    let (needed, updated_inv) =
1029        filter_needed_components(&requested, inventory_hex, &index.component_index)?;
1030
1031    let tag_refs: Vec<&str> = needed.iter().map(|s| s.as_str()).collect();
1032    let assets = collect_component_assets(protocol, &tag_refs, index)?;
1033
1034    let mut result = serde_json::Map::with_capacity(4);
1035    result.insert("templateStyles".into(), Value::Array(assets.styles));
1036    result.insert("templates".into(), Value::Object(assets.templates));
1037    result.insert("templateFunctions".into(), Value::Object(assets.functions));
1038    result.insert("inventory".into(), Value::String(updated_inv));
1039    Ok(Value::Object(result))
1040}
1041
1042/// Shared helper: collect templates and module CSS styles for a set of component tags.
1043fn collect_component_assets(
1044    protocol: &WebUIProtocol,
1045    tags: &[&str],
1046    index: &mut ProtocolIndex,
1047) -> Result<ComponentAssets, HandlerError> {
1048    let mut style_array = Vec::new();
1049    let mut tmpl_map = serde_json::Map::new();
1050    let mut function_map = serde_json::Map::new();
1051
1052    // Sort for deterministic output (reproducible responses, cache keys)
1053    let mut sorted_tags: Vec<&str> = tags.to_vec();
1054    sorted_tags.sort_unstable();
1055
1056    for tag in sorted_tags {
1057        let Some(component) = protocol.components.get(tag) else {
1058            continue;
1059        };
1060        if !has_template_payload(component) {
1061            continue;
1062        }
1063        if !component.css.is_empty() {
1064            // No nonce here — the per-request CSP nonce is attached
1065            // client-side by the router when it materializes each
1066            // importmap script tag into the DOM.
1067            let tag_html = crate::css_module::build_importmap_tag(tag, &component.css, None);
1068            style_array.push(Value::String(tag_html));
1069        }
1070        if !component.template_json.is_empty() {
1071            let template_value = cached_template_metadata(index, tag, &component.template_json)?;
1072            tmpl_map.insert(tag.to_string(), template_value);
1073            if !component.template_functions.is_empty() {
1074                function_map.insert(
1075                    tag.to_string(),
1076                    Value::String(component.template_functions.clone()),
1077                );
1078            }
1079        } else {
1080            tmpl_map.insert(tag.to_string(), Value::String(component.template.clone()));
1081        }
1082    }
1083
1084    Ok(ComponentAssets {
1085        styles: style_array,
1086        templates: tmpl_map,
1087        functions: function_map,
1088    })
1089}
1090
1091fn cached_template_metadata(
1092    index: &mut ProtocolIndex,
1093    tag: &str,
1094    template_json: &str,
1095) -> Result<Value, HandlerError> {
1096    if let Some(value) = index.template_metadata_cache.get(tag) {
1097        return Ok(value.clone());
1098    }
1099
1100    let value: Value = serde_json::from_str(template_json).map_err(|error| {
1101        HandlerError::Rendering(format!(
1102            "failed to parse template metadata for {tag}: {error}"
1103        ))
1104    })?;
1105    index
1106        .template_metadata_cache
1107        .insert(tag.to_string(), value.clone());
1108    Ok(value)
1109}
1110
1111// ── Route Chain ─────────────────────────────────────────────────────
1112
1113/// A single entry in the matched route chain, one per nesting level.
1114#[derive(Debug, Clone)]
1115pub struct RouteChainEntry {
1116    /// Component tag name for this route level.
1117    pub component: String,
1118    /// The route path pattern (as declared in the template).
1119    pub path: String,
1120    /// Bound route parameters at this level.
1121    pub params: HashMap<String, String>,
1122    /// Whether this route requires an exact match.
1123    pub exact: bool,
1124    /// Comma-separated allowlist of query parameters forwarded as attributes.
1125    pub allowed_query: String,
1126    /// When true, the router keeps the component alive across navigations.
1127    pub keep_alive: bool,
1128    /// Cache tag templates from the proto (e.g. `["thread:{threadId}", "inbox"]`).
1129    pub cache_tags: Vec<String>,
1130    /// Invalidation tag templates from the proto.
1131    pub invalidates: Vec<String>,
1132    /// Component tag name for pending/loading UI.
1133    pub pending_component: String,
1134    /// Component tag name for error boundary UI.
1135    pub error_component: String,
1136}
1137
1138impl RouteChainEntry {
1139    /// Serialize this entry to a JSON value ready for inclusion in a partial response.
1140    #[must_use]
1141    pub fn to_json(&self) -> Value {
1142        let mut obj = serde_json::Map::with_capacity(9);
1143        obj.insert("component".into(), Value::String(self.component.clone()));
1144        obj.insert("path".into(), Value::String(self.path.clone()));
1145        if !self.params.is_empty() {
1146            let params_obj: serde_json::Map<String, Value> = self
1147                .params
1148                .iter()
1149                .map(|(k, v)| (k.clone(), Value::String(v.clone())))
1150                .collect();
1151            obj.insert("params".into(), Value::Object(params_obj));
1152        }
1153        if self.exact {
1154            obj.insert("exact".into(), Value::Bool(true));
1155        }
1156        if !self.allowed_query.is_empty() {
1157            obj.insert(
1158                "allowedQuery".into(),
1159                Value::String(self.allowed_query.clone()),
1160            );
1161        }
1162        if self.keep_alive {
1163            obj.insert("keepAlive".into(), Value::Bool(true));
1164        }
1165        if !self.pending_component.is_empty() {
1166            obj.insert(
1167                "pendingComponent".into(),
1168                Value::String(self.pending_component.clone()),
1169            );
1170        }
1171        if !self.error_component.is_empty() {
1172            obj.insert(
1173                "errorComponent".into(),
1174                Value::String(self.error_component.clone()),
1175            );
1176        }
1177        if !self.invalidates.is_empty() {
1178            obj.insert(
1179                "invalidates".into(),
1180                Value::Array(
1181                    self.invalidates
1182                        .iter()
1183                        .map(|s| Value::String(s.clone()))
1184                        .collect(),
1185                ),
1186            );
1187        }
1188        Value::Object(obj)
1189    }
1190}
1191
1192/// Collect the matched route chain for a request path.
1193///
1194/// Walks the fragment graph from `entry_id`, follows the matched route at
1195/// each nesting level, and returns a chain entry per matched level.
1196pub fn collect_route_chain(
1197    protocol: &WebUIProtocol,
1198    entry_id: &str,
1199    request_path: &str,
1200    cache: &mut CompiledRouteCache,
1201) -> Vec<RouteChainEntry> {
1202    let mut chain = Vec::new();
1203    let mut visited_fragments = HashSet::new();
1204    let mut stack = vec![QueuedFragment {
1205        id: entry_id.to_string(),
1206        inventoryable: false,
1207        route_base: "/".to_string(),
1208    }];
1209
1210    while let Some(queued) = stack.pop() {
1211        if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
1212            continue;
1213        }
1214
1215        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1216            continue;
1217        };
1218
1219        let matched_route = route_renderer::find_best_route_match(
1220            &frag_list.fragments,
1221            request_path,
1222            &queued.route_base,
1223            cache,
1224        );
1225
1226        for frag in &frag_list.fragments {
1227            match frag.fragment.as_ref() {
1228                Some(Fragment::Component(component)) => {
1229                    stack.push(QueuedFragment {
1230                        id: component.fragment_id.clone(),
1231                        inventoryable: false,
1232                        route_base: queued.route_base.clone(),
1233                    });
1234                }
1235                Some(Fragment::Route(route_frag)) => {
1236                    let is_selected = matched_route
1237                        .as_ref()
1238                        .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1239                    if is_selected && !route_frag.fragment_id.is_empty() {
1240                        if let Some((_, ref rm)) = matched_route {
1241                            chain.push(RouteChainEntry {
1242                                component: route_frag.fragment_id.clone(),
1243                                path: route_frag.path.clone(),
1244                                params: rm.params.clone(),
1245                                exact: route_frag.exact,
1246                                allowed_query: route_frag.allowed_query.clone(),
1247                                keep_alive: route_frag.keep_alive,
1248                                cache_tags: route_frag.cache_tags.clone(),
1249                                invalidates: route_frag.invalidates.clone(),
1250                                pending_component: route_frag.pending_component.clone(),
1251                                error_component: route_frag.error_component.clone(),
1252                            });
1253
1254                            let child_route_base = route_matcher::compute_route_base(
1255                                request_path,
1256                                rm.consumed_segments,
1257                            );
1258
1259                            stack.push(QueuedFragment {
1260                                id: route_frag.fragment_id.clone(),
1261                                inventoryable: false,
1262                                route_base: child_route_base.clone(),
1263                            });
1264
1265                            // Walk nested children iteratively
1266                            collect_chain_from_children(
1267                                &route_frag.children,
1268                                request_path,
1269                                &child_route_base,
1270                                &mut chain,
1271                                cache,
1272                            );
1273                        }
1274                    }
1275                }
1276                _ => {}
1277            }
1278        }
1279    }
1280
1281    chain
1282}
1283
1284/// Iteratively collect chain entries from nested route children.
1285fn collect_chain_from_children(
1286    children: &[WebUIFragmentRoute],
1287    request_path: &str,
1288    route_base: &str,
1289    chain: &mut Vec<RouteChainEntry>,
1290    cache: &mut CompiledRouteCache,
1291) {
1292    let mut pending: Vec<(&[WebUIFragmentRoute], String)> =
1293        vec![(children, route_base.to_string())];
1294
1295    while let Some((current, base)) = pending.pop() {
1296        if let Some((idx, rm)) = select_best_child_route(current, request_path, &base, cache) {
1297            let matched = &current[idx];
1298            chain.push(RouteChainEntry {
1299                component: matched.fragment_id.clone(),
1300                path: matched.path.clone(),
1301                params: rm.params,
1302                exact: matched.exact,
1303                allowed_query: matched.allowed_query.clone(),
1304                keep_alive: matched.keep_alive,
1305                cache_tags: matched.cache_tags.clone(),
1306                invalidates: matched.invalidates.clone(),
1307                pending_component: matched.pending_component.clone(),
1308                error_component: matched.error_component.clone(),
1309            });
1310            if !matched.children.is_empty() {
1311                let child_base =
1312                    route_matcher::compute_route_base(request_path, rm.consumed_segments);
1313                pending.push((&matched.children, child_base));
1314            }
1315        }
1316    }
1317}
1318
1319#[cfg(test)]
1320mod tests {
1321    use super::*;
1322    use std::collections::HashMap;
1323    use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
1324
1325    #[test]
1326    fn test_parse_encode_inventory_roundtrip() {
1327        let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
1328        let hex = encode_inventory(&original);
1329        let decoded = parse_inventory(&hex).unwrap();
1330        assert_eq!(original, decoded);
1331    }
1332
1333    #[test]
1334    fn test_parse_inventory_empty() {
1335        let result = parse_inventory("").unwrap();
1336        assert!(result.is_empty());
1337    }
1338
1339    #[test]
1340    fn test_parse_inventory_rejects_odd_length() {
1341        let result = parse_inventory("abc");
1342        assert!(result.is_err());
1343    }
1344
1345    #[test]
1346    fn test_parse_inventory_rejects_invalid_hex() {
1347        let result = parse_inventory("zz");
1348        assert!(result.is_err());
1349    }
1350
1351    #[test]
1352    fn test_bitfield_set_and_check() {
1353        let mut inv = vec![0u8; 4];
1354        set_component(&mut inv, 0);
1355        set_component(&mut inv, 7);
1356        set_component(&mut inv, 8);
1357        set_component(&mut inv, 15);
1358        assert!(has_component(&inv, 0));
1359        assert!(has_component(&inv, 7));
1360        assert!(has_component(&inv, 8));
1361        assert!(has_component(&inv, 15));
1362        assert!(!has_component(&inv, 1));
1363        assert!(!has_component(&inv, 9));
1364    }
1365
1366    #[test]
1367    fn test_filter_needed_components_no_false_positives() {
1368        // With sequential indices, no two components share a bit
1369        let mut index = HashMap::new();
1370        index.insert("email-message".to_string(), 0);
1371        index.insert("o-button".to_string(), 1);
1372        index.insert("o-avatar".to_string(), 2);
1373
1374        let names = vec!["email-message".to_string(), "o-button".to_string()];
1375
1376        // Only o-button (index 1) is loaded — bit 1 set
1377        let mut inv = vec![0u8; 1];
1378        set_component(&mut inv, 1); // o-button
1379        let inv_hex = encode_inventory(&inv);
1380
1381        let (needed, _) = filter_needed_components(&names, &inv_hex, &index).unwrap();
1382        assert_eq!(needed.len(), 1);
1383        assert!(
1384            needed.contains(&"email-message".to_string()),
1385            "email-message must be needed: {needed:?}"
1386        );
1387    }
1388
1389    #[test]
1390    fn test_get_needed_components_empty_inventory() {
1391        let mut fragments = HashMap::new();
1392        fragments.insert(
1393            "app-shell".to_string(),
1394            FragmentList {
1395                fragments: vec![WebUIFragment::component("my-card")],
1396            },
1397        );
1398        fragments.insert(
1399            "my-card".to_string(),
1400            FragmentList {
1401                fragments: vec![WebUIFragment::raw("card")],
1402            },
1403        );
1404
1405        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1406        let comp_index = build_component_index(&protocol);
1407        let (needed, _inv) =
1408            get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
1409        assert!(needed.contains(&"app-shell".to_string()));
1410        assert!(needed.contains(&"my-card".to_string()));
1411    }
1412
1413    #[test]
1414    fn test_get_needed_components_skips_inventory() {
1415        let mut fragments = HashMap::new();
1416        fragments.insert(
1417            "app-shell".to_string(),
1418            FragmentList {
1419                fragments: vec![WebUIFragment::component("my-card")],
1420            },
1421        );
1422        fragments.insert(
1423            "my-card".to_string(),
1424            FragmentList {
1425                fragments: vec![WebUIFragment::raw("card")],
1426            },
1427        );
1428
1429        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1430        protocol
1431            .components
1432            .entry("app-shell".to_string())
1433            .or_default()
1434            .template = "<t></t>".to_string();
1435        protocol
1436            .components
1437            .entry("my-card".to_string())
1438            .or_default()
1439            .template = "<t></t>".to_string();
1440
1441        let comp_index = build_component_index(&protocol);
1442        let (_needed, inv_hex) =
1443            get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
1444        let (needed2, _) =
1445            get_needed_components(&protocol, "app-shell", &inv_hex, &comp_index).unwrap();
1446        assert!(needed2.is_empty());
1447    }
1448
1449    #[test]
1450    fn test_get_needed_components_skips_control_fragments() {
1451        let mut fragments = HashMap::new();
1452        fragments.insert(
1453            "app-shell".to_string(),
1454            FragmentList {
1455                fragments: vec![
1456                    WebUIFragment::if_cond(
1457                        webui_protocol::ConditionExpr::identifier("showNav"),
1458                        "if-shell",
1459                    ),
1460                    WebUIFragment::for_loop("item", "items", "for-items"),
1461                    WebUIFragment::attribute_template("title", "attr-title"),
1462                ],
1463            },
1464        );
1465        fragments.insert(
1466            "if-shell".to_string(),
1467            FragmentList {
1468                fragments: vec![WebUIFragment::component("mp-category-nav")],
1469            },
1470        );
1471        fragments.insert(
1472            "for-items".to_string(),
1473            FragmentList {
1474                fragments: vec![WebUIFragment::component("mp-product-card")],
1475            },
1476        );
1477        fragments.insert(
1478            "attr-title".to_string(),
1479            FragmentList {
1480                fragments: vec![WebUIFragment::raw("Products")],
1481            },
1482        );
1483        fragments.insert(
1484            "mp-category-nav".to_string(),
1485            FragmentList {
1486                fragments: vec![WebUIFragment::raw("<nav></nav>")],
1487            },
1488        );
1489        fragments.insert(
1490            "mp-product-card".to_string(),
1491            FragmentList {
1492                fragments: vec![WebUIFragment::raw("<article></article>")],
1493            },
1494        );
1495
1496        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1497        let comp_index = build_component_index(&protocol);
1498        let needed: HashSet<String> =
1499            get_needed_components(&protocol, "app-shell", "", &comp_index)
1500                .unwrap()
1501                .0
1502                .into_iter()
1503                .collect();
1504
1505        assert!(needed.contains("app-shell"));
1506        assert!(needed.contains("mp-category-nav"));
1507        assert!(needed.contains("mp-product-card"));
1508        assert!(!needed.contains("if-shell"));
1509        assert!(!needed.contains("for-items"));
1510        assert!(!needed.contains("attr-title"));
1511    }
1512
1513    #[test]
1514    fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
1515        let mut fragments = HashMap::new();
1516        fragments.insert(
1517            "index.html".to_string(),
1518            FragmentList {
1519                fragments: vec![WebUIFragment::component("mp-app")],
1520            },
1521        );
1522        fragments.insert(
1523            "mp-app".to_string(),
1524            FragmentList {
1525                fragments: vec![
1526                    WebUIFragment::component("mp-category-nav"),
1527                    WebUIFragment::route_from(WebUiFragmentRoute {
1528                        path: "/search/:category".into(),
1529                        fragment_id: "mp-search-page".into(),
1530                        exact: true,
1531                        keep_alive: false,
1532                        ..Default::default()
1533                    }),
1534                    WebUIFragment::route_from(WebUiFragmentRoute {
1535                        path: "/product/:handle".into(),
1536                        fragment_id: "mp-product-page".into(),
1537                        exact: true,
1538                        keep_alive: false,
1539                        ..Default::default()
1540                    }),
1541                ],
1542            },
1543        );
1544        fragments.insert(
1545            "mp-category-nav".to_string(),
1546            FragmentList {
1547                fragments: vec![WebUIFragment::raw("<nav></nav>")],
1548            },
1549        );
1550        fragments.insert(
1551            "mp-search-page".to_string(),
1552            FragmentList {
1553                fragments: vec![WebUIFragment::component("mp-product-grid")],
1554            },
1555        );
1556        fragments.insert(
1557            "mp-product-grid".to_string(),
1558            FragmentList {
1559                fragments: vec![WebUIFragment::raw("<div>grid</div>")],
1560            },
1561        );
1562        fragments.insert(
1563            "mp-product-page".to_string(),
1564            FragmentList {
1565                fragments: vec![WebUIFragment::component("mp-product-detail")],
1566            },
1567        );
1568        fragments.insert(
1569            "mp-product-detail".to_string(),
1570            FragmentList {
1571                fragments: vec![WebUIFragment::raw("<div>detail</div>")],
1572            },
1573        );
1574
1575        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1576        protocol
1577            .components
1578            .entry("mp-search-page".to_string())
1579            .or_default()
1580            .template = "<mp-search-page></mp-search-page>".to_string();
1581        protocol
1582            .components
1583            .entry("mp-product-page".to_string())
1584            .or_default()
1585            .template = "<mp-product-page></mp-product-page>".to_string();
1586
1587        let comp_index = build_component_index(&protocol);
1588        let (needed, _inv) = get_needed_components_for_request(
1589            &protocol,
1590            "index.html",
1591            "/search/shirts",
1592            "",
1593            &comp_index,
1594        )
1595        .unwrap();
1596        let needed: HashSet<String> = needed.into_iter().collect();
1597
1598        assert!(needed.contains("mp-app"));
1599        assert!(needed.contains("mp-category-nav"));
1600        assert!(needed.contains("mp-search-page"));
1601        assert!(needed.contains("mp-product-grid"));
1602        assert!(!needed.contains("mp-product-page"));
1603        assert!(!needed.contains("mp-product-detail"));
1604    }
1605
1606    #[test]
1607    fn test_get_needed_components_for_request_follows_nested_route_chain() {
1608        let mut fragments = HashMap::new();
1609        fragments.insert(
1610            "index.html".to_string(),
1611            FragmentList {
1612                fragments: vec![WebUIFragment::component("mp-app")],
1613            },
1614        );
1615        fragments.insert(
1616            "mp-app".to_string(),
1617            FragmentList {
1618                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1619                    path: "/account/*rest".into(),
1620                    fragment_id: "mp-account-shell".into(),
1621                    exact: false,
1622                    keep_alive: false,
1623                    ..Default::default()
1624                })],
1625            },
1626        );
1627        fragments.insert(
1628            "mp-account-shell".to_string(),
1629            FragmentList {
1630                fragments: vec![
1631                    WebUIFragment::component("mp-account-nav"),
1632                    WebUIFragment::route_from(WebUiFragmentRoute {
1633                        path: "/account/profile".into(),
1634                        fragment_id: "mp-profile-page".into(),
1635                        exact: true,
1636                        keep_alive: false,
1637                        ..Default::default()
1638                    }),
1639                    WebUIFragment::route_from(WebUiFragmentRoute {
1640                        path: "/account/orders/:id".into(),
1641                        fragment_id: "mp-order-page".into(),
1642                        exact: true,
1643                        keep_alive: false,
1644                        ..Default::default()
1645                    }),
1646                ],
1647            },
1648        );
1649        fragments.insert(
1650            "mp-account-nav".to_string(),
1651            FragmentList {
1652                fragments: vec![WebUIFragment::raw("<nav></nav>")],
1653            },
1654        );
1655        fragments.insert(
1656            "mp-profile-page".to_string(),
1657            FragmentList {
1658                fragments: vec![WebUIFragment::raw("<profile></profile>")],
1659            },
1660        );
1661        fragments.insert(
1662            "mp-order-page".to_string(),
1663            FragmentList {
1664                fragments: vec![WebUIFragment::component("mp-order-detail")],
1665            },
1666        );
1667        fragments.insert(
1668            "mp-order-detail".to_string(),
1669            FragmentList {
1670                fragments: vec![WebUIFragment::raw("<detail></detail>")],
1671            },
1672        );
1673
1674        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1675        protocol
1676            .components
1677            .entry("mp-account-shell".to_string())
1678            .or_default()
1679            .template = "<mp-account-shell></mp-account-shell>".to_string();
1680        protocol
1681            .components
1682            .entry("mp-profile-page".to_string())
1683            .or_default()
1684            .template = "<mp-profile-page></mp-profile-page>".to_string();
1685        protocol
1686            .components
1687            .entry("mp-order-page".to_string())
1688            .or_default()
1689            .template = "<mp-order-page></mp-order-page>".to_string();
1690
1691        let comp_index = build_component_index(&protocol);
1692        let (needed, _inv) = get_needed_components_for_request(
1693            &protocol,
1694            "index.html",
1695            "/account/orders/42",
1696            "",
1697            &comp_index,
1698        )
1699        .unwrap();
1700        let needed: HashSet<String> = needed.into_iter().collect();
1701
1702        assert!(needed.contains("mp-app"));
1703        assert!(needed.contains("mp-account-shell"));
1704        assert!(needed.contains("mp-account-nav"));
1705        assert!(needed.contains("mp-order-page"));
1706        assert!(needed.contains("mp-order-detail"));
1707        assert!(!needed.contains("mp-profile-page"));
1708    }
1709
1710    #[test]
1711    fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
1712        let mut fragments = HashMap::new();
1713        fragments.insert(
1714            "index.html".to_string(),
1715            FragmentList {
1716                fragments: vec![WebUIFragment::component("mp-app")],
1717            },
1718        );
1719        fragments.insert(
1720            "mp-app".to_string(),
1721            FragmentList {
1722                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1723                    path: "/search".into(),
1724                    fragment_id: "mp-search-page".into(),
1725                    exact: true,
1726                    keep_alive: false,
1727                    ..Default::default()
1728                })],
1729            },
1730        );
1731        fragments.insert(
1732            "mp-search-page".to_string(),
1733            FragmentList {
1734                fragments: vec![
1735                    WebUIFragment::if_cond(
1736                        webui_protocol::ConditionExpr::identifier("showFilters"),
1737                        "if-filters",
1738                    ),
1739                    WebUIFragment::for_loop("item", "items", "item-loop"),
1740                ],
1741            },
1742        );
1743        fragments.insert(
1744            "if-filters".to_string(),
1745            FragmentList {
1746                fragments: vec![WebUIFragment::component("mp-filter-panel")],
1747            },
1748        );
1749        fragments.insert(
1750            "item-loop".to_string(),
1751            FragmentList {
1752                fragments: vec![WebUIFragment::component("mp-item-card")],
1753            },
1754        );
1755        fragments.insert(
1756            "mp-filter-panel".to_string(),
1757            FragmentList {
1758                fragments: vec![WebUIFragment::raw("<filters></filters>")],
1759            },
1760        );
1761        fragments.insert(
1762            "mp-item-card".to_string(),
1763            FragmentList {
1764                fragments: vec![WebUIFragment::raw("<item></item>")],
1765            },
1766        );
1767
1768        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1769        protocol
1770            .components
1771            .entry("mp-search-page".to_string())
1772            .or_default()
1773            .template = "<mp-search-page></mp-search-page>".to_string();
1774
1775        let comp_index = build_component_index(&protocol);
1776        let (needed, _inv) =
1777            get_needed_components_for_request(&protocol, "index.html", "/search", "", &comp_index)
1778                .unwrap();
1779        let needed: HashSet<String> = needed.into_iter().collect();
1780
1781        assert!(needed.contains("mp-app"));
1782        assert!(needed.contains("mp-search-page"));
1783        assert!(needed.contains("mp-filter-panel"));
1784        assert!(needed.contains("mp-item-card"));
1785    }
1786
1787    #[test]
1788    fn test_get_needed_components_for_request_respects_inventory() {
1789        let mut fragments = HashMap::new();
1790        fragments.insert(
1791            "index.html".to_string(),
1792            FragmentList {
1793                fragments: vec![WebUIFragment::component("mp-app")],
1794            },
1795        );
1796        fragments.insert(
1797            "mp-app".to_string(),
1798            FragmentList {
1799                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1800                    path: "/search".into(),
1801                    fragment_id: "mp-search-page".into(),
1802                    exact: true,
1803                    keep_alive: false,
1804                    ..Default::default()
1805                })],
1806            },
1807        );
1808        fragments.insert(
1809            "mp-search-page".to_string(),
1810            FragmentList {
1811                fragments: vec![WebUIFragment::component("mp-product-grid")],
1812            },
1813        );
1814        fragments.insert(
1815            "mp-product-grid".to_string(),
1816            FragmentList {
1817                fragments: vec![WebUIFragment::raw("<grid></grid>")],
1818            },
1819        );
1820
1821        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1822        for name in ["mp-app", "mp-search-page", "mp-product-grid"] {
1823            protocol
1824                .components
1825                .entry(name.to_string())
1826                .or_default()
1827                .template = format!("<f-template id=\"{name}\"></f-template>");
1828        }
1829
1830        let comp_index = build_component_index(&protocol);
1831        let (_needed, inventory) =
1832            get_needed_components_for_request(&protocol, "index.html", "/search", "", &comp_index)
1833                .unwrap();
1834        let (needed_again, _) = get_needed_components_for_request(
1835            &protocol,
1836            "index.html",
1837            "/search",
1838            &inventory,
1839            &comp_index,
1840        )
1841        .unwrap();
1842        assert!(needed_again.is_empty());
1843    }
1844
1845    #[test]
1846    fn test_inventory_includes_boundary_components_for_unmatched_sibling_routes() {
1847        // Regression: pending/error components for routes that are NOT in the
1848        // matched chain at the current request path must still be inventoried.
1849        // The client renders pending UI before a sibling route's partial
1850        // resolves and error UI when it fails, so neither can be delivered by
1851        // the target route's own fetch — both must already be on the client.
1852        let mut fragments = HashMap::new();
1853        fragments.insert(
1854            "index.html".to_string(),
1855            FragmentList {
1856                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1857                    path: "/".into(),
1858                    fragment_id: "route-shell".into(),
1859                    children: vec![
1860                        WebUiFragmentRoute {
1861                            path: "slow".into(),
1862                            fragment_id: "page-slow".into(),
1863                            exact: true,
1864                            pending_component: "loading-skeleton".into(),
1865                            ..Default::default()
1866                        },
1867                        WebUiFragmentRoute {
1868                            path: "failing".into(),
1869                            fragment_id: "page-failing".into(),
1870                            exact: true,
1871                            error_component: "error-display".into(),
1872                            ..Default::default()
1873                        },
1874                    ],
1875                    ..Default::default()
1876                })],
1877            },
1878        );
1879        for id in [
1880            "route-shell",
1881            "page-slow",
1882            "page-failing",
1883            "loading-skeleton",
1884            "error-display",
1885        ] {
1886            fragments.insert(
1887                id.to_string(),
1888                FragmentList {
1889                    fragments: vec![WebUIFragment::raw("<x></x>")],
1890                },
1891            );
1892        }
1893
1894        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1895        for id in [
1896            "route-shell",
1897            "page-slow",
1898            "page-failing",
1899            "loading-skeleton",
1900            "error-display",
1901        ] {
1902            protocol
1903                .components
1904                .entry(id.to_string())
1905                .or_default()
1906                .template = format!("<f-template id=\"{id}\"></f-template>");
1907        }
1908
1909        // Request the root path: neither "slow" nor "failing" is in the matched chain.
1910        let reachable = collect_reachable_components_for_request(
1911            &protocol,
1912            "index.html",
1913            "/",
1914            &mut CompiledRouteCache::new(),
1915        );
1916        assert!(
1917            reachable.contains("loading-skeleton"),
1918            "pending component of an unmatched sibling route must be inventoried: {reachable:?}"
1919        );
1920        assert!(
1921            reachable.contains("error-display"),
1922            "error component of an unmatched sibling route must be inventoried: {reachable:?}"
1923        );
1924    }
1925
1926    #[test]
1927    fn test_render_partial_separates_module_styles_from_templates() {
1928        let mut fragments = HashMap::new();
1929        fragments.insert(
1930            "index.html".to_string(),
1931            FragmentList {
1932                fragments: vec![WebUIFragment::component("my-page")],
1933            },
1934        );
1935        fragments.insert(
1936            "my-page".to_string(),
1937            FragmentList {
1938                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1939            },
1940        );
1941
1942        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1943        let component = protocol
1944            .components
1945            .entry("my-page".to_string())
1946            .or_default();
1947        component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
1948        component.css = ".page{color:red}".to_string();
1949
1950        let mut index = ProtocolIndex::new(&protocol);
1951        let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
1952        let styles = partial["templateStyles"]
1953            .as_array()
1954            .expect("templateStyles should be an array");
1955        assert_eq!(styles.len(), 1);
1956        let style_html = styles[0].as_str().unwrap_or_default();
1957        assert!(
1958            style_html.starts_with(r#"<script type="importmap""#)
1959                && style_html.contains(r#""my-page":"data:text/css,"#),
1960            "module style entry should be an importmap registering the component specifier: {style_html}"
1961        );
1962        assert!(
1963            style_html.contains(".page{color:red}"),
1964            "module style entry should contain the CSS content verbatim inside the data: URI: {style_html}"
1965        );
1966
1967        // templates should contain only JSON-safe metadata
1968        let templates = partial["templates"]
1969            .as_object()
1970            .expect("templates should be an object");
1971        assert_eq!(templates.len(), 1);
1972        let template = templates.get("my-page").expect("my-page template");
1973        assert_eq!(template["h"], "<p>page</p>");
1974    }
1975
1976    #[test]
1977    fn test_render_partial_link_strategy_has_empty_template_styles() {
1978        // Link-strategy components have css_href but no css content.
1979        // templateStyles should be empty; templates should contain metadata.
1980        let mut fragments = HashMap::new();
1981        fragments.insert(
1982            "index.html".to_string(),
1983            FragmentList {
1984                fragments: vec![WebUIFragment::component("my-page")],
1985            },
1986        );
1987        fragments.insert(
1988            "my-page".to_string(),
1989            FragmentList {
1990                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1991            },
1992        );
1993
1994        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1995        let component = protocol
1996            .components
1997            .entry("my-page".to_string())
1998            .or_default();
1999        component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
2000        component.css_href = "my-page.css".to_string();
2001        // css is empty — Link strategy stores href, not content
2002
2003        let mut index = ProtocolIndex::new(&protocol);
2004        let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
2005        let styles = partial["templateStyles"]
2006            .as_array()
2007            .expect("templateStyles should be an array");
2008        let templates = partial["templates"]
2009            .as_object()
2010            .expect("templates should be an object");
2011
2012        assert!(
2013            styles.is_empty(),
2014            "Link strategy should produce empty templateStyles: {styles:?}"
2015        );
2016        assert_eq!(templates.len(), 1, "should include template metadata");
2017    }
2018
2019    #[test]
2020    fn test_render_partial_style_strategy_has_empty_template_styles() {
2021        // Style-strategy components have CSS embedded in the template HTML
2022        // (as <style>...</style>), not in component.css.
2023        // templateStyles should be empty; templates should contain metadata.
2024        let mut fragments = HashMap::new();
2025        fragments.insert(
2026            "index.html".to_string(),
2027            FragmentList {
2028                fragments: vec![WebUIFragment::component("my-page")],
2029            },
2030        );
2031        fragments.insert(
2032            "my-page".to_string(),
2033            FragmentList {
2034                fragments: vec![WebUIFragment::raw("<p>page</p>")],
2035            },
2036        );
2037
2038        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2039        let component = protocol
2040            .components
2041            .entry("my-page".to_string())
2042            .or_default();
2043        // Style strategy: CSS is inside the template HTML, not in component.css
2044        component.template_json = r#"{"h":"<style>.p{color:red}</style><p>page</p>"}"#.to_string();
2045        // css is empty for Style strategy
2046
2047        let mut index = ProtocolIndex::new(&protocol);
2048        let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
2049        let styles = partial["templateStyles"]
2050            .as_array()
2051            .expect("templateStyles should be an array");
2052        let templates = partial["templates"]
2053            .as_object()
2054            .expect("templates should be an object");
2055
2056        assert!(
2057            styles.is_empty(),
2058            "Style strategy should produce empty templateStyles: {styles:?}"
2059        );
2060        assert_eq!(templates.len(), 1, "should include template metadata");
2061        assert!(
2062            templates
2063                .get("my-page")
2064                .and_then(|template| template["h"].as_str())
2065                .unwrap_or_default()
2066                .contains("<style>"),
2067            "Style strategy template should contain inline <style> tag"
2068        );
2069    }
2070
2071    #[test]
2072    fn test_render_partial_empty_styles_for_no_css_components() {
2073        let mut fragments = HashMap::new();
2074        fragments.insert(
2075            "index.html".to_string(),
2076            FragmentList {
2077                fragments: vec![WebUIFragment::component("my-page")],
2078            },
2079        );
2080        fragments.insert(
2081            "my-page".to_string(),
2082            FragmentList {
2083                fragments: vec![WebUIFragment::raw("<p>page</p>")],
2084            },
2085        );
2086
2087        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2088        let component = protocol
2089            .components
2090            .entry("my-page".to_string())
2091            .or_default();
2092        component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
2093        // No CSS — simulates Link or Style mode
2094
2095        let mut index = ProtocolIndex::new(&protocol);
2096        let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
2097        let styles = partial["templateStyles"]
2098            .as_array()
2099            .expect("templateStyles should be an array");
2100        assert!(
2101            styles.is_empty(),
2102            "templateStyles should be empty when components have no CSS"
2103        );
2104    }
2105
2106    #[test]
2107    fn test_render_partial_sends_styles_even_when_templates_filtered_by_inventory() {
2108        let mut fragments = HashMap::new();
2109        fragments.insert(
2110            "index.html".to_string(),
2111            FragmentList {
2112                fragments: vec![WebUIFragment::component("my-page")],
2113            },
2114        );
2115        fragments.insert(
2116            "my-page".to_string(),
2117            FragmentList {
2118                fragments: vec![WebUIFragment::raw("<p>page</p>")],
2119            },
2120        );
2121
2122        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2123        let component = protocol
2124            .components
2125            .entry("my-page".to_string())
2126            .or_default();
2127        component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
2128        component.css = ".page{color:red}".to_string();
2129
2130        let mut index = ProtocolIndex::new(&protocol);
2131        // First call to establish inventory
2132        let partial1 = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
2133        let inv = partial1["inventory"].as_str().unwrap_or_default();
2134        assert!(!inv.is_empty());
2135
2136        // Second call with the inventory — both templates and styles should be empty
2137        // because the inventory covers this component.  The SSR handler emits all
2138        // module style definitions in <head> for inventoried components, so the
2139        // client already has the CSS definition.
2140        let partial2 = render_partial(&protocol, "index.html", "/", inv, &mut index).unwrap();
2141        let styles = partial2["templateStyles"]
2142            .as_array()
2143            .expect("templateStyles should be an array");
2144        let templates = partial2["templates"]
2145            .as_object()
2146            .expect("templates should be an object");
2147
2148        assert!(
2149            templates.is_empty(),
2150            "templates should be empty when inventory is full"
2151        );
2152        assert!(
2153            styles.is_empty(),
2154            "module styles should be empty when inventory is full — SSR already placed them"
2155        );
2156    }
2157
2158    #[test]
2159    fn test_non_route_siblings_included_in_needed_components() {
2160        // Reproduces the commerce app layout: mp-app has both route children
2161        // (via outlet) AND non-route sibling components (mp-cart-panel, mp-footer).
2162        // All siblings must be in the needed set so their module CSS definitions
2163        // are emitted in <head> during SSR.
2164        let mut fragments = HashMap::new();
2165
2166        // Entry → app-shell component
2167        fragments.insert(
2168            "index.html".to_string(),
2169            FragmentList {
2170                fragments: vec![WebUIFragment::component("app-shell")],
2171            },
2172        );
2173
2174        // app-shell has: navbar (component), route (outlet), cart-panel (component)
2175        fragments.insert(
2176            "app-shell".to_string(),
2177            FragmentList {
2178                fragments: vec![
2179                    WebUIFragment::component("my-navbar"),
2180                    WebUIFragment::route_from(WebUiFragmentRoute {
2181                        path: "/about".into(),
2182                        fragment_id: "page-about".into(),
2183                        exact: true,
2184                        keep_alive: false,
2185                        ..Default::default()
2186                    }),
2187                    WebUIFragment::component("cart-panel"),
2188                ],
2189            },
2190        );
2191
2192        // Leaf fragments
2193        fragments.insert(
2194            "my-navbar".to_string(),
2195            FragmentList {
2196                fragments: vec![WebUIFragment::raw("<nav/>")],
2197            },
2198        );
2199        fragments.insert(
2200            "page-about".to_string(),
2201            FragmentList {
2202                fragments: vec![WebUIFragment::raw("<h1>About</h1>")],
2203            },
2204        );
2205        fragments.insert(
2206            "cart-panel".to_string(),
2207            FragmentList {
2208                fragments: vec![WebUIFragment::raw("<aside>Cart</aside>")],
2209            },
2210        );
2211
2212        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2213        for name in ["app-shell", "my-navbar", "page-about", "cart-panel"] {
2214            let comp = protocol.components.entry(name.to_string()).or_default();
2215            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
2216            comp.css = format!(".{name}{{display:block}}");
2217        }
2218
2219        let comp_index = build_component_index(&protocol);
2220        let (needed, _inv) =
2221            get_needed_components_for_request(&protocol, "index.html", "/about", "", &comp_index)
2222                .unwrap();
2223
2224        assert!(
2225            needed.contains(&"app-shell".to_string()),
2226            "app-shell should be needed: {needed:?}"
2227        );
2228        assert!(
2229            needed.contains(&"my-navbar".to_string()),
2230            "my-navbar (non-route sibling) should be needed: {needed:?}"
2231        );
2232        assert!(
2233            needed.contains(&"page-about".to_string()),
2234            "page-about (active route) should be needed: {needed:?}"
2235        );
2236        assert!(
2237            needed.contains(&"cart-panel".to_string()),
2238            "cart-panel (non-route sibling) should be needed: {needed:?}"
2239        );
2240    }
2241
2242    // ── RouteChainEntry.to_json + allowed_query tests ────────────────
2243
2244    #[test]
2245    fn test_chain_entry_to_json_includes_allowed_query() {
2246        let entry = RouteChainEntry {
2247            component: "compose-page".into(),
2248            path: "compose".into(),
2249            params: HashMap::new(),
2250            exact: true,
2251            allowed_query: "action,to,subject".into(),
2252            keep_alive: false,
2253            cache_tags: Vec::new(),
2254            invalidates: Vec::new(),
2255            pending_component: String::new(),
2256            error_component: String::new(),
2257        };
2258        let json = entry.to_json();
2259        assert_eq!(json["allowedQuery"], "action,to,subject");
2260    }
2261
2262    #[test]
2263    fn test_chain_entry_to_json_omits_empty_allowed_query() {
2264        let entry = RouteChainEntry {
2265            component: "home-page".into(),
2266            path: "/".into(),
2267            params: HashMap::new(),
2268            exact: true,
2269            allowed_query: String::new(),
2270            keep_alive: false,
2271            cache_tags: Vec::new(),
2272            invalidates: Vec::new(),
2273            pending_component: String::new(),
2274            error_component: String::new(),
2275        };
2276        let json = entry.to_json();
2277        assert!(
2278            json.get("allowedQuery").is_none(),
2279            "empty allowed_query should not appear in JSON"
2280        );
2281    }
2282
2283    #[test]
2284    fn test_collect_route_chain_carries_allowed_query() {
2285        let mut fragments = HashMap::new();
2286        fragments.insert(
2287            "index.html".to_string(),
2288            FragmentList {
2289                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2290                    path: "/".into(),
2291                    fragment_id: "app-shell".into(),
2292                    exact: false,
2293                    children: vec![WebUiFragmentRoute {
2294                        path: "compose".into(),
2295                        fragment_id: "compose-page".into(),
2296                        exact: true,
2297                        allowed_query: "action,to".into(),
2298                        keep_alive: false,
2299                        ..Default::default()
2300                    }],
2301                    keep_alive: false,
2302                    ..Default::default()
2303                })],
2304            },
2305        );
2306        fragments.insert(
2307            "app-shell".to_string(),
2308            FragmentList {
2309                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2310            },
2311        );
2312        fragments.insert(
2313            "compose-page".to_string(),
2314            FragmentList {
2315                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
2316            },
2317        );
2318        let protocol = WebUIProtocol::new(fragments);
2319
2320        let chain = collect_route_chain(
2321            &protocol,
2322            "index.html",
2323            "/compose",
2324            &mut CompiledRouteCache::new(),
2325        );
2326        assert_eq!(chain.len(), 2);
2327        assert_eq!(chain[0].component, "app-shell");
2328        assert!(chain[0].allowed_query.is_empty());
2329        assert_eq!(chain[1].component, "compose-page");
2330        assert_eq!(chain[1].allowed_query, "action,to");
2331    }
2332
2333    #[test]
2334    fn test_render_component_templates_returns_template_and_css() {
2335        let mut fragments = HashMap::new();
2336        fragments.insert(
2337            "settings-dialog".to_string(),
2338            FragmentList {
2339                fragments: vec![WebUIFragment::raw("<div class='dialog'>Settings</div>")],
2340            },
2341        );
2342        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2343        let comp = protocol
2344            .components
2345            .entry("settings-dialog".to_string())
2346            .or_default();
2347        comp.template_json = r#"{"h":"<div>Settings</div>"}"#.to_string();
2348        comp.css = ".dialog{position:fixed}".to_string();
2349
2350        let mut index = ProtocolIndex::new(&protocol);
2351        let result =
2352            render_component_templates(&protocol, &["settings-dialog"], "", &mut index).unwrap();
2353        let templates = result["templates"].as_object().expect("templates object");
2354        let styles = result["templateStyles"].as_array().expect("styles array");
2355
2356        assert_eq!(templates.len(), 1);
2357        assert_eq!(templates["settings-dialog"]["h"], "<div>Settings</div>");
2358        assert_eq!(styles.len(), 1);
2359        let style_html = styles[0].as_str().unwrap();
2360        assert!(
2361            style_html.starts_with(r#"<script type="importmap""#)
2362                && style_html.contains(r#""settings-dialog":"data:text/css,"#),
2363            "templateStyles entry should be an importmap registering settings-dialog: {style_html}"
2364        );
2365        // CSS content is embedded inside the data: URI verbatim — `{`, `}`,
2366        // `\` are not in the percent-encode set.
2367        assert!(
2368            style_html.contains(".dialog{position:fixed}"),
2369            "templateStyles entry should contain the CSS content verbatim: {style_html}"
2370        );
2371        assert!(
2372            index
2373                .template_metadata_cache
2374                .contains_key("settings-dialog"),
2375            "parsed static template metadata should be cached in ProtocolIndex"
2376        );
2377    }
2378
2379    #[test]
2380    fn test_render_component_templates_respects_inventory() {
2381        let mut fragments = HashMap::new();
2382        fragments.insert(
2383            "my-dialog".to_string(),
2384            FragmentList {
2385                fragments: vec![WebUIFragment::raw("<div>Dialog</div>")],
2386            },
2387        );
2388        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2389        let comp = protocol
2390            .components
2391            .entry("my-dialog".to_string())
2392            .or_default();
2393        comp.template_json = r#"{"h":"<div>Dialog</div>"}"#.to_string();
2394        comp.css = ".d{color:red}".to_string();
2395
2396        let mut index = ProtocolIndex::new(&protocol);
2397        // First call: no inventory → should return the component
2398        let result1 =
2399            render_component_templates(&protocol, &["my-dialog"], "", &mut index).unwrap();
2400        let inv = result1["inventory"].as_str().expect("inventory string");
2401        assert_eq!(result1["templates"].as_object().unwrap().len(), 1);
2402
2403        // Second call with inventory → component already loaded, should skip
2404        let result2 =
2405            render_component_templates(&protocol, &["my-dialog"], inv, &mut index).unwrap();
2406        assert_eq!(result2["templates"].as_object().unwrap().len(), 0);
2407        assert_eq!(result2["templateStyles"].as_array().unwrap().len(), 0);
2408    }
2409
2410    #[test]
2411    fn test_render_component_templates_unknown_component_returns_empty() {
2412        let fragments = HashMap::new();
2413        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2414
2415        let mut index = ProtocolIndex::new(&protocol);
2416        let result =
2417            render_component_templates(&protocol, &["nonexistent-widget"], "", &mut index).unwrap();
2418        assert_eq!(result["templates"].as_object().unwrap().len(), 0);
2419        assert_eq!(result["templateStyles"].as_array().unwrap().len(), 0);
2420    }
2421
2422    #[test]
2423    fn test_render_partial_includes_sibling_boundary_templates() {
2424        let mut fragments = HashMap::new();
2425        fragments.insert(
2426            "index.html".to_string(),
2427            FragmentList {
2428                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2429                    path: "/".into(),
2430                    fragment_id: "app-shell".into(),
2431                    exact: false,
2432                    children: vec![
2433                        WebUiFragmentRoute {
2434                            path: "inbox".into(),
2435                            fragment_id: "inbox-page".into(),
2436                            exact: true,
2437                            pending_component: "inbox-loading".into(),
2438                            ..Default::default()
2439                        },
2440                        WebUiFragmentRoute {
2441                            path: "settings".into(),
2442                            fragment_id: "settings-page".into(),
2443                            exact: true,
2444                            pending_component: "settings-loading".into(),
2445                            error_component: "settings-error".into(),
2446                            ..Default::default()
2447                        },
2448                    ],
2449                    ..Default::default()
2450                })],
2451            },
2452        );
2453
2454        for name in [
2455            "app-shell",
2456            "inbox-page",
2457            "inbox-loading",
2458            "settings-page",
2459            "settings-loading",
2460            "settings-error",
2461        ] {
2462            fragments.insert(
2463                name.to_string(),
2464                FragmentList {
2465                    fragments: vec![WebUIFragment::raw(format!("<p>{name}</p>"))],
2466                },
2467            );
2468        }
2469
2470        let mut protocol = WebUIProtocol::new(fragments);
2471        for name in [
2472            "app-shell",
2473            "inbox-page",
2474            "inbox-loading",
2475            "settings-page",
2476            "settings-loading",
2477            "settings-error",
2478        ] {
2479            protocol
2480                .components
2481                .entry(name.to_string())
2482                .or_default()
2483                .template_json = format!(r#"{{"h":"<p>{name}</p>"}}"#);
2484        }
2485
2486        let mut index = ProtocolIndex::new(&protocol);
2487        let partial = render_partial(&protocol, "index.html", "/inbox", "", &mut index).unwrap();
2488        let templates = partial["templates"].as_object().unwrap();
2489
2490        assert!(
2491            templates.contains_key("settings-loading"),
2492            "sibling pending template should be preloaded for route transitions: {partial}"
2493        );
2494        assert!(
2495            templates.contains_key("settings-error"),
2496            "sibling error template should be preloaded for failed route transitions: {partial}"
2497        );
2498        let chain = partial["chain"].as_array().unwrap();
2499        assert_eq!(chain.len(), 2);
2500        assert_eq!(chain[0]["component"], "app-shell");
2501        assert_eq!(chain[1]["component"], "inbox-page");
2502    }
2503
2504    // ── resolve_tag_templates tests ──────────────────────────────────
2505
2506    #[test]
2507    fn test_resolve_tag_templates_no_placeholders() {
2508        let tags = vec!["inbox".to_string(), "counts".to_string()];
2509        let params = HashMap::new();
2510        let resolved = resolve_tag_templates(&tags, &params);
2511        assert_eq!(resolved, vec!["inbox", "counts"]);
2512    }
2513
2514    #[test]
2515    fn test_resolve_tag_templates_with_params() {
2516        let tags = vec!["thread:{threadId}".to_string(), "inbox".to_string()];
2517        let mut params = HashMap::new();
2518        params.insert("threadId".to_string(), "42".to_string());
2519        let resolved = resolve_tag_templates(&tags, &params);
2520        assert_eq!(resolved, vec!["thread:42", "inbox"]);
2521    }
2522
2523    #[test]
2524    fn test_resolve_tag_templates_multiple_params() {
2525        let tags = vec!["folder:{folderId}:user:{userId}".to_string()];
2526        let mut params = HashMap::new();
2527        params.insert("folderId".to_string(), "drafts".to_string());
2528        params.insert("userId".to_string(), "abc".to_string());
2529        let resolved = resolve_tag_templates(&tags, &params);
2530        assert_eq!(resolved, vec!["folder:drafts:user:abc"]);
2531    }
2532
2533    #[test]
2534    fn test_resolve_tag_templates_missing_param_left_unresolved() {
2535        let tags = vec!["thread:{threadId}".to_string()];
2536        let params = HashMap::new();
2537        let resolved = resolve_tag_templates(&tags, &params);
2538        assert_eq!(resolved, vec!["thread:{threadId}"]);
2539    }
2540
2541    // ── Chain entry to_json with new fields ───────────────────────
2542
2543    #[test]
2544    fn test_chain_entry_to_json_includes_new_fields() {
2545        let entry = RouteChainEntry {
2546            component: "mail-thread".into(),
2547            path: "email/:threadId".into(),
2548            params: {
2549                let mut p = HashMap::new();
2550                p.insert("threadId".to_string(), "42".to_string());
2551                p
2552            },
2553            exact: true,
2554            allowed_query: String::new(),
2555            keep_alive: false,
2556            cache_tags: vec!["thread:42".to_string()],
2557            invalidates: vec!["inbox".to_string(), "counts".to_string()],
2558            pending_component: "mail-skeleton".into(),
2559            error_component: "error-page".into(),
2560        };
2561        let json = entry.to_json();
2562        assert_eq!(json["pendingComponent"], "mail-skeleton");
2563        assert_eq!(json["errorComponent"], "error-page");
2564        let inv = json["invalidates"].as_array().unwrap();
2565        assert_eq!(inv.len(), 2);
2566        assert_eq!(inv[0], "inbox");
2567        assert_eq!(inv[1], "counts");
2568    }
2569
2570    #[test]
2571    fn test_chain_entry_to_json_omits_empty_new_fields() {
2572        let entry = RouteChainEntry {
2573            component: "home-page".into(),
2574            path: "/".into(),
2575            params: HashMap::new(),
2576            exact: true,
2577            allowed_query: String::new(),
2578            keep_alive: false,
2579            cache_tags: Vec::new(),
2580            invalidates: Vec::new(),
2581            pending_component: String::new(),
2582            error_component: String::new(),
2583        };
2584        let json = entry.to_json();
2585        assert!(json.get("pendingComponent").is_none());
2586        assert!(json.get("errorComponent").is_none());
2587        assert!(json.get("invalidates").is_none());
2588    }
2589
2590    // ── render_partial with cache tags ────────────────────────────
2591
2592    #[test]
2593    fn test_render_partial_includes_resolved_cache_tags() {
2594        let mut fragments = HashMap::new();
2595        fragments.insert(
2596            "index.html".to_string(),
2597            FragmentList {
2598                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2599                    path: "/".into(),
2600                    fragment_id: "app-shell".into(),
2601                    exact: false,
2602                    children: vec![WebUiFragmentRoute {
2603                        path: "email/:threadId".into(),
2604                        fragment_id: "mail-thread".into(),
2605                        exact: true,
2606                        cache_tags: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
2607                        ..Default::default()
2608                    }],
2609                    cache_tags: vec!["folders".to_string()],
2610                    ..Default::default()
2611                })],
2612            },
2613        );
2614        fragments.insert(
2615            "app-shell".to_string(),
2616            FragmentList {
2617                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2618            },
2619        );
2620        fragments.insert(
2621            "mail-thread".to_string(),
2622            FragmentList {
2623                fragments: vec![WebUIFragment::raw("<p>Thread</p>")],
2624            },
2625        );
2626        let protocol = WebUIProtocol::new(fragments);
2627
2628        let mut index = ProtocolIndex::new(&protocol);
2629        let partial = render_partial(&protocol, "index.html", "/email/42", "", &mut index).unwrap();
2630        let tags = partial["cacheTags"].as_array().unwrap();
2631        let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
2632        assert!(
2633            tag_strings.contains(&"folders"),
2634            "should contain parent tags"
2635        );
2636        assert!(
2637            tag_strings.contains(&"thread:42"),
2638            "should resolve threadId param to 42"
2639        );
2640        assert!(tag_strings.contains(&"inbox"), "should contain static tags");
2641    }
2642
2643    // ── render_action_response tests ─────────────────────────────
2644
2645    #[test]
2646    fn test_render_action_response_returns_resolved_invalidation_tags() {
2647        let mut fragments = HashMap::new();
2648        fragments.insert(
2649            "index.html".to_string(),
2650            FragmentList {
2651                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2652                    path: "/".into(),
2653                    fragment_id: "app-shell".into(),
2654                    exact: false,
2655                    children: vec![WebUiFragmentRoute {
2656                        path: "compose".into(),
2657                        fragment_id: "compose-page".into(),
2658                        exact: true,
2659                        invalidates: vec![
2660                            "inbox".to_string(),
2661                            "sent".to_string(),
2662                            "counts".to_string(),
2663                        ],
2664                        ..Default::default()
2665                    }],
2666                    ..Default::default()
2667                })],
2668            },
2669        );
2670        fragments.insert(
2671            "app-shell".to_string(),
2672            FragmentList {
2673                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2674            },
2675        );
2676        fragments.insert(
2677            "compose-page".to_string(),
2678            FragmentList {
2679                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
2680            },
2681        );
2682        let protocol = WebUIProtocol::new(fragments);
2683
2684        let mut index = ProtocolIndex::new(&protocol);
2685        let result = render_action_response(
2686            &protocol,
2687            serde_json::json!({"ok": true}),
2688            "index.html",
2689            "/compose",
2690            &mut index,
2691        );
2692
2693        let tags = result["invalidateTags"].as_array().unwrap();
2694        let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
2695        assert_eq!(tag_strings, vec!["inbox", "sent", "counts"]);
2696        assert_eq!(result["state"]["ok"], true);
2697    }
2698
2699    #[test]
2700    fn test_render_action_response_resolves_param_placeholders() {
2701        let mut fragments = HashMap::new();
2702        fragments.insert(
2703            "index.html".to_string(),
2704            FragmentList {
2705                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2706                    path: "/".into(),
2707                    fragment_id: "app-shell".into(),
2708                    exact: false,
2709                    children: vec![WebUiFragmentRoute {
2710                        path: "email/:threadId/reply".into(),
2711                        fragment_id: "reply-page".into(),
2712                        exact: true,
2713                        invalidates: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
2714                        ..Default::default()
2715                    }],
2716                    ..Default::default()
2717                })],
2718            },
2719        );
2720        fragments.insert(
2721            "app-shell".to_string(),
2722            FragmentList {
2723                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2724            },
2725        );
2726        fragments.insert(
2727            "reply-page".to_string(),
2728            FragmentList {
2729                fragments: vec![WebUIFragment::raw("<p>Reply</p>")],
2730            },
2731        );
2732        let protocol = WebUIProtocol::new(fragments);
2733
2734        let mut index = ProtocolIndex::new(&protocol);
2735        let result = render_action_response(
2736            &protocol,
2737            serde_json::json!({}),
2738            "index.html",
2739            "/email/42/reply",
2740            &mut index,
2741        );
2742
2743        let tags = result["invalidateTags"].as_array().unwrap();
2744        let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
2745        assert!(tag_strings.contains(&"thread:42"));
2746        assert!(tag_strings.contains(&"inbox"));
2747    }
2748}