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