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;
12use serde_json::Value;
13use std::collections::{HashMap, HashSet};
14use webui_protocol::{web_ui_fragment::Fragment, WebUIFragment, WebUIFragmentRoute, WebUIProtocol};
15
16// ── Component Inventory ─────────────────────────────────────────────────
17
18/// Build a deterministic component-name → bit-index map from the protocol.
19///
20/// Derives names from fragment keys (hyphenated = custom element) since that
21/// is the source of truth regardless of whether a plugin populated
22/// `protocol.components`. Components are sorted alphabetically; index =
23/// position in that order.
24pub fn build_component_index(protocol: &WebUIProtocol) -> HashMap<String, u32> {
25    let mut names: HashSet<&String> = HashSet::new();
26    for key in protocol.fragments.keys() {
27        if key.contains('-') {
28            names.insert(key);
29        }
30    }
31    let mut sorted: Vec<&String> = names.into_iter().collect();
32    sorted.sort_unstable();
33    sorted
34        .iter()
35        .enumerate()
36        .map(|(i, n)| {
37            // Index count bounded by component registry size, well within u32 range
38            #[allow(clippy::cast_possible_truncation)]
39            let idx = i as u32;
40            ((*n).clone(), idx)
41        })
42        .collect()
43}
44
45/// Check if a component's bit is set in the inventory bitfield.
46fn has_component(inventory: &[u8], index: u32) -> bool {
47    let byte_idx = (index / 8) as usize;
48    let bit_idx = index % 8;
49    byte_idx < inventory.len() && inventory[byte_idx] & (1 << bit_idx) != 0
50}
51
52/// Set a component's bit in the inventory bitfield.
53fn set_component(inventory: &mut Vec<u8>, index: u32) {
54    let byte_idx = (index / 8) as usize;
55    let bit_idx = index % 8;
56    if byte_idx >= inventory.len() {
57        inventory.resize(byte_idx + 1, 0);
58    }
59    inventory[byte_idx] |= 1 << bit_idx;
60}
61
62/// Parse a hex-encoded inventory string into bytes.
63pub fn parse_inventory(hex: &str) -> Vec<u8> {
64    let mut bytes = Vec::with_capacity(hex.len() / 2);
65    let mut chars = hex.bytes();
66    while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
67        if let (Some(h), Some(l)) = (hex_nibble(hi), hex_nibble(lo)) {
68            bytes.push((h << 4) | l);
69        }
70    }
71    bytes
72}
73
74fn hex_nibble(byte: u8) -> Option<u8> {
75    match byte {
76        b'0'..=b'9' => Some(byte - b'0'),
77        b'a'..=b'f' => Some(byte - b'a' + 10),
78        b'A'..=b'F' => Some(byte - b'A' + 10),
79        _ => None,
80    }
81}
82
83/// Encode an inventory bitfield as a hex string.
84pub fn encode_inventory(inv: &[u8]) -> String {
85    inv.iter()
86        .fold(String::with_capacity(inv.len() * 2), |mut acc, b| {
87            use std::fmt::Write;
88            let _ = write!(acc, "{b:02x}");
89            acc
90        })
91}
92
93/// Walk the protocol fragment graph from `entry_id` and return the names of
94/// all components the route needs that are NOT in the client's inventory.
95///
96/// Returns `(needed_names, updated_inventory_hex)`.
97pub fn get_needed_components(
98    protocol: &WebUIProtocol,
99    entry_id: &str,
100    inventory_hex: &str,
101) -> (Vec<String>, String) {
102    let component_names = collect_inventoryable_components(protocol, entry_id, None, true);
103    let index = build_component_index(protocol);
104    filter_needed_components(&component_names, inventory_hex, &index)
105}
106
107/// Walk the protocol from the persistent `entry_id` and return the component
108/// templates needed for the current `request_path`.
109///
110/// Returns `(needed_names, updated_inventory_hex)`.
111pub fn get_needed_components_for_request(
112    protocol: &WebUIProtocol,
113    entry_id: &str,
114    request_path: &str,
115    inventory_hex: &str,
116) -> (Vec<String>, String) {
117    let component_names =
118        collect_inventoryable_components(protocol, entry_id, Some(request_path), false);
119    let index = build_component_index(protocol);
120    filter_needed_components(&component_names, inventory_hex, &index)
121}
122
123/// Filter components against the client's inventory bitfield using sequential indices.
124/// Zero collisions — each component has a unique bit.
125///
126/// Returns the missing component names and the updated inventory hex string.
127#[must_use]
128pub fn filter_needed_components(
129    component_names: &HashSet<String>,
130    inventory_hex: &str,
131    index: &HashMap<String, u32>,
132) -> (Vec<String>, String) {
133    let client_inv = parse_inventory(inventory_hex);
134    let mut updated_inv = client_inv.clone();
135
136    let mut ordered_names: Vec<&String> = component_names.iter().collect();
137    ordered_names.sort_unstable();
138
139    let mut needed = Vec::with_capacity(ordered_names.len());
140    for name in ordered_names {
141        if let Some(&idx) = index.get(name.as_str()) {
142            if !has_component(&client_inv, idx) {
143                needed.push(name.clone());
144            }
145            set_component(&mut updated_inv, idx);
146        } else {
147            // Component exists in the fragment graph but has no index entry
148            // (no protocol.components record). It can't be tracked in the
149            // bitfield, so we must always send it.
150            needed.push(name.clone());
151        }
152    }
153
154    (needed, encode_inventory(&updated_inv))
155}
156
157#[derive(Debug)]
158struct QueuedFragment {
159    id: String,
160    inventoryable: bool,
161    /// Base path for resolving relative route paths at this level.
162    route_base: String,
163}
164
165/// Walk the fragment graph from `entry_id` and collect all inventoryable component names.
166///
167/// Uses an iterative stack-based traversal. When `request_path` is provided,
168/// the walk follows only the best-matching route at each nesting level (route-aware).
169/// Without a request path, all route branches are followed conservatively.
170///
171/// Components are marked `inventoryable` when they have a corresponding entry
172/// in `protocol.components` with a non-empty template — these are the components
173/// whose f-template HTML the client may need during navigation.
174fn collect_inventoryable_components(
175    protocol: &WebUIProtocol,
176    entry_id: &str,
177    request_path: Option<&str>,
178    root_inventoryable: bool,
179) -> HashSet<String> {
180    let mut visited_fragments = HashSet::new();
181    let mut component_ids = HashSet::new();
182    let mut stack = vec![QueuedFragment {
183        id: entry_id.to_string(),
184        inventoryable: root_inventoryable,
185        route_base: "/".to_string(),
186    }];
187
188    while let Some(queued) = stack.pop() {
189        if queued.id.is_empty() {
190            continue;
191        }
192
193        if queued.inventoryable {
194            component_ids.insert(queued.id.clone());
195        }
196
197        if !visited_fragments.insert(queued.id.clone()) {
198            continue;
199        }
200
201        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
202            continue;
203        };
204
205        let matched_route = request_path
206            .and_then(|path| find_best_route_match(&frag_list.fragments, path, &queued.route_base));
207
208        for frag in &frag_list.fragments {
209            match frag.fragment.as_ref() {
210                Some(Fragment::Component(component)) => {
211                    stack.push(QueuedFragment {
212                        id: component.fragment_id.clone(),
213                        inventoryable: true,
214                        route_base: queued.route_base.clone(),
215                    });
216                }
217                Some(Fragment::ForLoop(for_loop)) => {
218                    stack.push(QueuedFragment {
219                        id: for_loop.fragment_id.clone(),
220                        inventoryable: false,
221                        route_base: queued.route_base.clone(),
222                    });
223                }
224                Some(Fragment::IfCond(if_cond)) => {
225                    stack.push(QueuedFragment {
226                        id: if_cond.fragment_id.clone(),
227                        inventoryable: false,
228                        route_base: queued.route_base.clone(),
229                    });
230                }
231                Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
232                    stack.push(QueuedFragment {
233                        id: attr.template.clone(),
234                        inventoryable: false,
235                        route_base: queued.route_base.clone(),
236                    });
237                }
238                Some(Fragment::Route(route_frag)) => {
239                    let is_selected = matched_route
240                        .as_ref()
241                        .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
242                    if is_selected && !route_frag.fragment_id.is_empty() {
243                        // Compute new route base from consumed segments
244                        let child_route_base = if let Some((_, ref rm)) = matched_route {
245                            if let Some(path) = request_path {
246                                route_matcher::compute_route_base(path, rm.consumed_segments)
247                            } else {
248                                queued.route_base.clone()
249                            }
250                        } else {
251                            queued.route_base.clone()
252                        };
253
254                        stack.push(QueuedFragment {
255                            id: route_frag.fragment_id.clone(),
256                            inventoryable: protocol
257                                .components
258                                .get(&route_frag.fragment_id)
259                                .is_some_and(|c| !c.template.is_empty()),
260                            route_base: child_route_base.clone(),
261                        });
262
263                        // Walk nested child routes to find the next matched level.
264                        // This mirrors the handler's outlet rendering: match children
265                        // against the request path and follow the matched chain.
266                        if !route_frag.children.is_empty() {
267                            if let Some(path) = request_path {
268                                walk_route_children(
269                                    &route_frag.children,
270                                    path,
271                                    &child_route_base,
272                                    protocol,
273                                    &mut stack,
274                                );
275                            }
276                        }
277                    }
278                }
279                _ => {}
280            }
281        }
282    }
283
284    component_ids
285}
286
287/// Walk nested route children to find matched routes and add their
288/// components to the inventory stack. Mirrors the handler's outlet rendering.
289fn walk_route_children(
290    children: &[WebUIFragmentRoute],
291    request_path: &str,
292    route_base: &str,
293    protocol: &WebUIProtocol,
294    stack: &mut Vec<QueuedFragment>,
295) {
296    let mut current = children;
297    let mut base = route_base.to_string();
298
299    loop {
300        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
301        for (idx, child) in current.iter().enumerate() {
302            let resolved = route_matcher::resolve_route_path(&child.path, &base);
303            if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
304            {
305                let is_better = best
306                    .as_ref()
307                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
308                if is_better {
309                    best = Some((idx, m));
310                }
311            }
312        }
313
314        let Some((idx, ref rm)) = best else { break };
315        let matched = &current[idx];
316        if matched.fragment_id.is_empty() {
317            break;
318        }
319
320        let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
321
322        stack.push(QueuedFragment {
323            id: matched.fragment_id.clone(),
324            inventoryable: protocol
325                .components
326                .get(&matched.fragment_id)
327                .is_some_and(|c| !c.template.is_empty()),
328            route_base: child_base.clone(),
329        });
330
331        if matched.children.is_empty() {
332            break;
333        }
334        current = &matched.children;
335        base = child_base;
336    }
337}
338
339/// Pre-scan sibling route fragments and return the best match info.
340///
341/// `route_base` is used to resolve relative paths (starting with `./`).
342fn find_best_route_match(
343    fragments: &[WebUIFragment],
344    request_path: &str,
345    route_base: &str,
346) -> Option<(String, route_matcher::RouteMatch)> {
347    let mut best: Option<(String, route_matcher::RouteMatch)> = None;
348
349    for item in fragments {
350        if let Some(Fragment::Route(route_frag)) = item.fragment.as_ref() {
351            let resolved_path = route_matcher::resolve_route_path(&route_frag.path, route_base);
352            if let Some(m) =
353                route_matcher::match_single_route(&resolved_path, request_path, route_frag.exact)
354            {
355                let is_better = best
356                    .as_ref()
357                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
358
359                if is_better {
360                    best = Some((route_fragment_key(route_frag).to_string(), m));
361                }
362            }
363        }
364    }
365
366    best
367}
368
369fn route_fragment_key(route_frag: &WebUIFragmentRoute) -> &str {
370    route_frag.fragment_id.as_str()
371}
372
373/// Walk the fragment graph following matched routes and collect all route
374/// parameters from every level of the active route chain.
375///
376/// This is used by the dev server to inject nested route params into state.
377pub fn collect_nested_route_params(
378    protocol: &WebUIProtocol,
379    entry_id: &str,
380    request_path: &str,
381) -> HashMap<String, String> {
382    let mut all_params = HashMap::new();
383    let mut visited_fragments = HashSet::new();
384    let mut stack = vec![QueuedFragment {
385        id: entry_id.to_string(),
386        inventoryable: false,
387        route_base: "/".to_string(),
388    }];
389
390    while let Some(queued) = stack.pop() {
391        if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
392            continue;
393        }
394
395        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
396            continue;
397        };
398
399        let matched_route =
400            find_best_route_match(&frag_list.fragments, request_path, &queued.route_base);
401
402        for frag in &frag_list.fragments {
403            match frag.fragment.as_ref() {
404                Some(Fragment::Component(component)) => {
405                    stack.push(QueuedFragment {
406                        id: component.fragment_id.clone(),
407                        inventoryable: false,
408                        route_base: queued.route_base.clone(),
409                    });
410                }
411                Some(Fragment::Route(route_frag)) => {
412                    let is_selected = matched_route
413                        .as_ref()
414                        .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
415                    if is_selected && !route_frag.fragment_id.is_empty() {
416                        if let Some((_, ref rm)) = matched_route {
417                            // Collect params from this route level
418                            all_params.extend(rm.params.clone());
419
420                            let child_route_base = route_matcher::compute_route_base(
421                                request_path,
422                                rm.consumed_segments,
423                            );
424
425                            stack.push(QueuedFragment {
426                                id: route_frag.fragment_id.clone(),
427                                inventoryable: false,
428                                route_base: child_route_base.clone(),
429                            });
430
431                            // Walk nested children to collect params from deeper levels
432                            collect_params_from_children(
433                                &route_frag.children,
434                                request_path,
435                                &child_route_base,
436                                &mut all_params,
437                            );
438                        }
439                    }
440                }
441                _ => {}
442            }
443        }
444    }
445
446    all_params
447}
448
449/// Recursively collect route params from nested route children.
450fn collect_params_from_children(
451    children: &[WebUIFragmentRoute],
452    request_path: &str,
453    route_base: &str,
454    all_params: &mut HashMap<String, String>,
455) {
456    let mut current = children;
457    let mut base = route_base.to_string();
458
459    loop {
460        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
461        for (idx, child) in current.iter().enumerate() {
462            let resolved = route_matcher::resolve_route_path(&child.path, &base);
463            if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
464            {
465                let is_better = best
466                    .as_ref()
467                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
468                if is_better {
469                    best = Some((idx, m));
470                }
471            }
472        }
473
474        let Some((idx, rm)) = best else { break };
475        all_params.extend(rm.params);
476        let matched = &current[idx];
477        if matched.children.is_empty() {
478            break;
479        }
480        let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
481        current = &matched.children;
482        base = child_base;
483    }
484}
485
486// ── Partial Response ────────────────────────────────────────────────
487
488/// Produce a complete JSON partial response for client-side navigation.
489///
490/// Combines route templates, inventory, and matched route chain into a single
491/// JSON value. Host servers include this directly in their response alongside
492/// the application `state`.
493///
494/// Returns a `serde_json::Value` object with fields:
495/// - `state`: the application state passed through
496/// - `templateStyles`: module CSS definition tags for inventory-new components (empty for Link/Style)
497/// - `templates`: client template payloads the client doesn't already have (inventory-filtered)
498/// - `inventory`: updated hex bitmask
499/// - `path`: the request path
500/// - `chain`: matched route chain array
501#[must_use]
502pub fn render_partial(
503    protocol: &WebUIProtocol,
504    state: Value,
505    entry_id: &str,
506    request_path: &str,
507    inventory_hex: &str,
508) -> Value {
509    // Filter by inventory — only send components the client doesn't have.
510    let (needed_names, updated_inv) =
511        get_needed_components_for_request(protocol, entry_id, request_path, inventory_hex);
512
513    // Build the matched route chain
514    let chain = collect_route_chain(protocol, entry_id, request_path);
515
516    // Collect templates + CSS using the shared helper
517    let tag_refs: Vec<&str> = needed_names.iter().map(|s| s.as_str()).collect();
518    let (style_array, tmpl_array) = collect_component_assets(protocol, &tag_refs);
519
520    let chain_array = Value::Array(chain.iter().map(RouteChainEntry::to_json).collect());
521
522    let mut result = serde_json::Map::with_capacity(6);
523    result.insert("state".into(), state);
524    result.insert("templateStyles".into(), Value::Array(style_array));
525    result.insert("templates".into(), Value::Array(tmpl_array));
526    result.insert("inventory".into(), Value::String(updated_inv));
527    result.insert("path".into(), Value::String(request_path.to_string()));
528    result.insert("chain".into(), chain_array);
529    Value::Object(result)
530}
531
532/// Return compiled templates and CSS for specific components by tag name.
533///
534/// This supports on-demand loading of components that aren't part of the
535/// route tree (e.g., dialogs, popovers) — the client calls this before
536/// creating the element so templates + styles are registered without FOUC.
537///
538/// Uses the same inventory bitfield as partial navigation to avoid sending
539/// templates the client already has.
540#[must_use]
541pub fn render_component_templates(
542    protocol: &WebUIProtocol,
543    component_tags: &[&str],
544    inventory_hex: &str,
545) -> Value {
546    let requested: HashSet<String> = component_tags.iter().map(|s| s.to_string()).collect();
547    let index = build_component_index(protocol);
548    let (needed, updated_inv) = filter_needed_components(&requested, inventory_hex, &index);
549
550    let tag_refs: Vec<&str> = needed.iter().map(|s| s.as_str()).collect();
551    let (style_array, tmpl_array) = collect_component_assets(protocol, &tag_refs);
552
553    let mut result = serde_json::Map::with_capacity(3);
554    result.insert("templateStyles".into(), Value::Array(style_array));
555    result.insert("templates".into(), Value::Array(tmpl_array));
556    result.insert("inventory".into(), Value::String(updated_inv));
557    Value::Object(result)
558}
559
560/// Shared helper: collect templates and module CSS styles for a set of component tags.
561fn collect_component_assets(protocol: &WebUIProtocol, tags: &[&str]) -> (Vec<Value>, Vec<Value>) {
562    let mut style_array = Vec::new();
563    let mut tmpl_array = Vec::new();
564
565    // Sort for deterministic output (reproducible responses, cache keys)
566    let mut sorted_tags: Vec<&str> = tags.to_vec();
567    sorted_tags.sort_unstable();
568
569    for tag in sorted_tags {
570        let Some(component) = protocol.components.get(tag) else {
571            continue;
572        };
573        if component.template.is_empty() {
574            continue;
575        }
576        if !component.css.is_empty() {
577            let mut s = String::with_capacity(40 + tag.len() + component.css.len());
578            s.push_str("<style type=\"module\" specifier=\"");
579            s.push_str(tag);
580            s.push_str("\">");
581            s.push_str(&component.css);
582            s.push_str("</style>");
583            style_array.push(Value::String(s));
584        }
585        tmpl_array.push(Value::String(component.template.clone()));
586    }
587
588    (style_array, tmpl_array)
589}
590
591// ── Route Chain ─────────────────────────────────────────────────────
592
593/// A single entry in the matched route chain, one per nesting level.
594#[derive(Debug, Clone)]
595pub struct RouteChainEntry {
596    /// Component tag name for this route level.
597    pub component: String,
598    /// The route path pattern (as declared in the template).
599    pub path: String,
600    /// Bound route parameters at this level.
601    pub params: HashMap<String, String>,
602    /// Whether this route requires an exact match.
603    pub exact: bool,
604    /// Comma-separated allowlist of query parameters forwarded as attributes.
605    pub allowed_query: String,
606}
607
608impl RouteChainEntry {
609    /// Serialize this entry to a JSON value ready for inclusion in a partial response.
610    #[must_use]
611    pub fn to_json(&self) -> Value {
612        let mut obj = serde_json::Map::with_capacity(5);
613        obj.insert("component".into(), Value::String(self.component.clone()));
614        obj.insert("path".into(), Value::String(self.path.clone()));
615        if !self.params.is_empty() {
616            let params_obj: serde_json::Map<String, Value> = self
617                .params
618                .iter()
619                .map(|(k, v)| (k.clone(), Value::String(v.clone())))
620                .collect();
621            obj.insert("params".into(), Value::Object(params_obj));
622        }
623        if self.exact {
624            obj.insert("exact".into(), Value::Bool(true));
625        }
626        if !self.allowed_query.is_empty() {
627            obj.insert(
628                "allowedQuery".into(),
629                Value::String(self.allowed_query.clone()),
630            );
631        }
632        Value::Object(obj)
633    }
634}
635
636/// Collect the matched route chain as a JSON array value.
637///
638/// Convenience wrapper around [`collect_route_chain`] that serializes the
639/// result into a `serde_json::Value::Array` ready for inclusion in a JSON
640/// partial response. Host servers in any language can include this directly
641/// without reimplementing the serialization.
642#[must_use]
643pub fn collect_route_chain_json(
644    protocol: &WebUIProtocol,
645    entry_id: &str,
646    request_path: &str,
647) -> Value {
648    let chain = collect_route_chain(protocol, entry_id, request_path);
649    Value::Array(chain.iter().map(RouteChainEntry::to_json).collect())
650}
651
652/// Collect the matched route chain for a request path.
653///
654/// Walks the fragment graph from `entry_id`, follows the matched route at
655/// each nesting level, and returns a chain entry per matched level.
656pub fn collect_route_chain(
657    protocol: &WebUIProtocol,
658    entry_id: &str,
659    request_path: &str,
660) -> Vec<RouteChainEntry> {
661    let mut chain = Vec::new();
662    let mut visited_fragments = HashSet::new();
663    let mut stack = vec![QueuedFragment {
664        id: entry_id.to_string(),
665        inventoryable: false,
666        route_base: "/".to_string(),
667    }];
668
669    while let Some(queued) = stack.pop() {
670        if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
671            continue;
672        }
673
674        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
675            continue;
676        };
677
678        let matched_route =
679            find_best_route_match(&frag_list.fragments, request_path, &queued.route_base);
680
681        for frag in &frag_list.fragments {
682            match frag.fragment.as_ref() {
683                Some(Fragment::Component(component)) => {
684                    stack.push(QueuedFragment {
685                        id: component.fragment_id.clone(),
686                        inventoryable: false,
687                        route_base: queued.route_base.clone(),
688                    });
689                }
690                Some(Fragment::Route(route_frag)) => {
691                    let is_selected = matched_route
692                        .as_ref()
693                        .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
694                    if is_selected && !route_frag.fragment_id.is_empty() {
695                        if let Some((_, ref rm)) = matched_route {
696                            chain.push(RouteChainEntry {
697                                component: route_frag.fragment_id.clone(),
698                                path: route_frag.path.clone(),
699                                params: rm.params.clone(),
700                                exact: route_frag.exact,
701                                allowed_query: route_frag.allowed_query.clone(),
702                            });
703
704                            let child_route_base = route_matcher::compute_route_base(
705                                request_path,
706                                rm.consumed_segments,
707                            );
708
709                            stack.push(QueuedFragment {
710                                id: route_frag.fragment_id.clone(),
711                                inventoryable: false,
712                                route_base: child_route_base.clone(),
713                            });
714
715                            // Walk nested children iteratively
716                            collect_chain_from_children(
717                                &route_frag.children,
718                                request_path,
719                                &child_route_base,
720                                &mut chain,
721                            );
722                        }
723                    }
724                }
725                _ => {}
726            }
727        }
728    }
729
730    chain
731}
732
733/// Iteratively collect chain entries from nested route children.
734fn collect_chain_from_children(
735    children: &[WebUIFragmentRoute],
736    request_path: &str,
737    route_base: &str,
738    chain: &mut Vec<RouteChainEntry>,
739) {
740    let mut pending: Vec<(&[WebUIFragmentRoute], String)> =
741        vec![(children, route_base.to_string())];
742
743    while let Some((current, base)) = pending.pop() {
744        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
745        for (idx, child) in current.iter().enumerate() {
746            let resolved = route_matcher::resolve_route_path(&child.path, &base);
747            if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
748            {
749                let is_better = best
750                    .as_ref()
751                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
752                if is_better {
753                    best = Some((idx, m));
754                }
755            }
756        }
757
758        if let Some((idx, rm)) = best {
759            let matched = &current[idx];
760            chain.push(RouteChainEntry {
761                component: matched.fragment_id.clone(),
762                path: matched.path.clone(),
763                params: rm.params,
764                exact: matched.exact,
765                allowed_query: matched.allowed_query.clone(),
766            });
767            if !matched.children.is_empty() {
768                let child_base =
769                    route_matcher::compute_route_base(request_path, rm.consumed_segments);
770                pending.push((&matched.children, child_base));
771            }
772        }
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use std::collections::HashMap;
780    use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
781
782    #[test]
783    fn test_parse_encode_inventory_roundtrip() {
784        let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
785        let hex = encode_inventory(&original);
786        let decoded = parse_inventory(&hex);
787        assert_eq!(original, decoded);
788    }
789
790    #[test]
791    fn test_bitfield_set_and_check() {
792        let mut inv = vec![0u8; 4];
793        set_component(&mut inv, 0);
794        set_component(&mut inv, 7);
795        set_component(&mut inv, 8);
796        set_component(&mut inv, 15);
797        assert!(has_component(&inv, 0));
798        assert!(has_component(&inv, 7));
799        assert!(has_component(&inv, 8));
800        assert!(has_component(&inv, 15));
801        assert!(!has_component(&inv, 1));
802        assert!(!has_component(&inv, 9));
803    }
804
805    #[test]
806    fn test_filter_needed_components_no_false_positives() {
807        // With sequential indices, no two components share a bit
808        let mut index = HashMap::new();
809        index.insert("email-message".to_string(), 0);
810        index.insert("o-button".to_string(), 1);
811        index.insert("o-avatar".to_string(), 2);
812
813        let mut names = HashSet::new();
814        names.insert("email-message".to_string());
815        names.insert("o-button".to_string());
816
817        // Only o-button (index 1) is loaded — bit 1 set
818        let mut inv = vec![0u8; 1];
819        set_component(&mut inv, 1); // o-button
820        let inv_hex = encode_inventory(&inv);
821
822        let (needed, _) = filter_needed_components(&names, &inv_hex, &index);
823        assert_eq!(needed.len(), 1);
824        assert!(
825            needed.contains(&"email-message".to_string()),
826            "email-message must be needed: {needed:?}"
827        );
828    }
829
830    #[test]
831    fn test_get_needed_components_empty_inventory() {
832        let mut fragments = HashMap::new();
833        fragments.insert(
834            "app-shell".to_string(),
835            FragmentList {
836                fragments: vec![WebUIFragment::component("my-card")],
837            },
838        );
839        fragments.insert(
840            "my-card".to_string(),
841            FragmentList {
842                fragments: vec![WebUIFragment::raw("card")],
843            },
844        );
845
846        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
847        let (needed, _inv) = get_needed_components(&protocol, "app-shell", "");
848        assert!(needed.contains(&"app-shell".to_string()));
849        assert!(needed.contains(&"my-card".to_string()));
850    }
851
852    #[test]
853    fn test_get_needed_components_skips_inventory() {
854        let mut fragments = HashMap::new();
855        fragments.insert(
856            "app-shell".to_string(),
857            FragmentList {
858                fragments: vec![WebUIFragment::component("my-card")],
859            },
860        );
861        fragments.insert(
862            "my-card".to_string(),
863            FragmentList {
864                fragments: vec![WebUIFragment::raw("card")],
865            },
866        );
867
868        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
869        protocol
870            .components
871            .entry("app-shell".to_string())
872            .or_default()
873            .template = "<t></t>".to_string();
874        protocol
875            .components
876            .entry("my-card".to_string())
877            .or_default()
878            .template = "<t></t>".to_string();
879
880        let (_needed, inv_hex) = get_needed_components(&protocol, "app-shell", "");
881        let (needed2, _) = get_needed_components(&protocol, "app-shell", &inv_hex);
882        assert!(needed2.is_empty());
883    }
884
885    #[test]
886    fn test_get_needed_components_skips_control_fragments() {
887        let mut fragments = HashMap::new();
888        fragments.insert(
889            "app-shell".to_string(),
890            FragmentList {
891                fragments: vec![
892                    WebUIFragment::if_cond(
893                        webui_protocol::ConditionExpr::identifier("showNav"),
894                        "if-shell",
895                    ),
896                    WebUIFragment::for_loop("item", "items", "for-items"),
897                    WebUIFragment::attribute_template("title", "attr-title"),
898                ],
899            },
900        );
901        fragments.insert(
902            "if-shell".to_string(),
903            FragmentList {
904                fragments: vec![WebUIFragment::component("mp-category-nav")],
905            },
906        );
907        fragments.insert(
908            "for-items".to_string(),
909            FragmentList {
910                fragments: vec![WebUIFragment::component("mp-product-card")],
911            },
912        );
913        fragments.insert(
914            "attr-title".to_string(),
915            FragmentList {
916                fragments: vec![WebUIFragment::raw("Products")],
917            },
918        );
919        fragments.insert(
920            "mp-category-nav".to_string(),
921            FragmentList {
922                fragments: vec![WebUIFragment::raw("<nav></nav>")],
923            },
924        );
925        fragments.insert(
926            "mp-product-card".to_string(),
927            FragmentList {
928                fragments: vec![WebUIFragment::raw("<article></article>")],
929            },
930        );
931
932        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
933        let needed: HashSet<String> = get_needed_components(&protocol, "app-shell", "")
934            .0
935            .into_iter()
936            .collect();
937
938        assert!(needed.contains("app-shell"));
939        assert!(needed.contains("mp-category-nav"));
940        assert!(needed.contains("mp-product-card"));
941        assert!(!needed.contains("if-shell"));
942        assert!(!needed.contains("for-items"));
943        assert!(!needed.contains("attr-title"));
944    }
945
946    #[test]
947    fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
948        let mut fragments = HashMap::new();
949        fragments.insert(
950            "index.html".to_string(),
951            FragmentList {
952                fragments: vec![WebUIFragment::component("mp-app")],
953            },
954        );
955        fragments.insert(
956            "mp-app".to_string(),
957            FragmentList {
958                fragments: vec![
959                    WebUIFragment::component("mp-category-nav"),
960                    WebUIFragment::route_from(WebUiFragmentRoute {
961                        path: "/search/:category".into(),
962                        fragment_id: "mp-search-page".into(),
963                        exact: true,
964                        ..Default::default()
965                    }),
966                    WebUIFragment::route_from(WebUiFragmentRoute {
967                        path: "/product/:handle".into(),
968                        fragment_id: "mp-product-page".into(),
969                        exact: true,
970                        ..Default::default()
971                    }),
972                ],
973            },
974        );
975        fragments.insert(
976            "mp-category-nav".to_string(),
977            FragmentList {
978                fragments: vec![WebUIFragment::raw("<nav></nav>")],
979            },
980        );
981        fragments.insert(
982            "mp-search-page".to_string(),
983            FragmentList {
984                fragments: vec![WebUIFragment::component("mp-product-grid")],
985            },
986        );
987        fragments.insert(
988            "mp-product-grid".to_string(),
989            FragmentList {
990                fragments: vec![WebUIFragment::raw("<div>grid</div>")],
991            },
992        );
993        fragments.insert(
994            "mp-product-page".to_string(),
995            FragmentList {
996                fragments: vec![WebUIFragment::component("mp-product-detail")],
997            },
998        );
999        fragments.insert(
1000            "mp-product-detail".to_string(),
1001            FragmentList {
1002                fragments: vec![WebUIFragment::raw("<div>detail</div>")],
1003            },
1004        );
1005
1006        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1007        protocol
1008            .components
1009            .entry("mp-search-page".to_string())
1010            .or_default()
1011            .template = "<mp-search-page></mp-search-page>".to_string();
1012        protocol
1013            .components
1014            .entry("mp-product-page".to_string())
1015            .or_default()
1016            .template = "<mp-product-page></mp-product-page>".to_string();
1017
1018        let (needed, _inv) =
1019            get_needed_components_for_request(&protocol, "index.html", "/search/shirts", "");
1020        let needed: HashSet<String> = needed.into_iter().collect();
1021
1022        assert!(needed.contains("mp-app"));
1023        assert!(needed.contains("mp-category-nav"));
1024        assert!(needed.contains("mp-search-page"));
1025        assert!(needed.contains("mp-product-grid"));
1026        assert!(!needed.contains("mp-product-page"));
1027        assert!(!needed.contains("mp-product-detail"));
1028    }
1029
1030    #[test]
1031    fn test_get_needed_components_for_request_follows_nested_route_chain() {
1032        let mut fragments = HashMap::new();
1033        fragments.insert(
1034            "index.html".to_string(),
1035            FragmentList {
1036                fragments: vec![WebUIFragment::component("mp-app")],
1037            },
1038        );
1039        fragments.insert(
1040            "mp-app".to_string(),
1041            FragmentList {
1042                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1043                    path: "/account/*rest".into(),
1044                    fragment_id: "mp-account-shell".into(),
1045                    exact: false,
1046                    ..Default::default()
1047                })],
1048            },
1049        );
1050        fragments.insert(
1051            "mp-account-shell".to_string(),
1052            FragmentList {
1053                fragments: vec![
1054                    WebUIFragment::component("mp-account-nav"),
1055                    WebUIFragment::route_from(WebUiFragmentRoute {
1056                        path: "/account/profile".into(),
1057                        fragment_id: "mp-profile-page".into(),
1058                        exact: true,
1059                        ..Default::default()
1060                    }),
1061                    WebUIFragment::route_from(WebUiFragmentRoute {
1062                        path: "/account/orders/:id".into(),
1063                        fragment_id: "mp-order-page".into(),
1064                        exact: true,
1065                        ..Default::default()
1066                    }),
1067                ],
1068            },
1069        );
1070        fragments.insert(
1071            "mp-account-nav".to_string(),
1072            FragmentList {
1073                fragments: vec![WebUIFragment::raw("<nav></nav>")],
1074            },
1075        );
1076        fragments.insert(
1077            "mp-profile-page".to_string(),
1078            FragmentList {
1079                fragments: vec![WebUIFragment::raw("<profile></profile>")],
1080            },
1081        );
1082        fragments.insert(
1083            "mp-order-page".to_string(),
1084            FragmentList {
1085                fragments: vec![WebUIFragment::component("mp-order-detail")],
1086            },
1087        );
1088        fragments.insert(
1089            "mp-order-detail".to_string(),
1090            FragmentList {
1091                fragments: vec![WebUIFragment::raw("<detail></detail>")],
1092            },
1093        );
1094
1095        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1096        protocol
1097            .components
1098            .entry("mp-account-shell".to_string())
1099            .or_default()
1100            .template = "<mp-account-shell></mp-account-shell>".to_string();
1101        protocol
1102            .components
1103            .entry("mp-profile-page".to_string())
1104            .or_default()
1105            .template = "<mp-profile-page></mp-profile-page>".to_string();
1106        protocol
1107            .components
1108            .entry("mp-order-page".to_string())
1109            .or_default()
1110            .template = "<mp-order-page></mp-order-page>".to_string();
1111
1112        let (needed, _inv) =
1113            get_needed_components_for_request(&protocol, "index.html", "/account/orders/42", "");
1114        let needed: HashSet<String> = needed.into_iter().collect();
1115
1116        assert!(needed.contains("mp-app"));
1117        assert!(needed.contains("mp-account-shell"));
1118        assert!(needed.contains("mp-account-nav"));
1119        assert!(needed.contains("mp-order-page"));
1120        assert!(needed.contains("mp-order-detail"));
1121        assert!(!needed.contains("mp-profile-page"));
1122    }
1123
1124    #[test]
1125    fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
1126        let mut fragments = HashMap::new();
1127        fragments.insert(
1128            "index.html".to_string(),
1129            FragmentList {
1130                fragments: vec![WebUIFragment::component("mp-app")],
1131            },
1132        );
1133        fragments.insert(
1134            "mp-app".to_string(),
1135            FragmentList {
1136                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1137                    path: "/search".into(),
1138                    fragment_id: "mp-search-page".into(),
1139                    exact: true,
1140                    ..Default::default()
1141                })],
1142            },
1143        );
1144        fragments.insert(
1145            "mp-search-page".to_string(),
1146            FragmentList {
1147                fragments: vec![
1148                    WebUIFragment::if_cond(
1149                        webui_protocol::ConditionExpr::identifier("showFilters"),
1150                        "if-filters",
1151                    ),
1152                    WebUIFragment::for_loop("item", "items", "item-loop"),
1153                ],
1154            },
1155        );
1156        fragments.insert(
1157            "if-filters".to_string(),
1158            FragmentList {
1159                fragments: vec![WebUIFragment::component("mp-filter-panel")],
1160            },
1161        );
1162        fragments.insert(
1163            "item-loop".to_string(),
1164            FragmentList {
1165                fragments: vec![WebUIFragment::component("mp-item-card")],
1166            },
1167        );
1168        fragments.insert(
1169            "mp-filter-panel".to_string(),
1170            FragmentList {
1171                fragments: vec![WebUIFragment::raw("<filters></filters>")],
1172            },
1173        );
1174        fragments.insert(
1175            "mp-item-card".to_string(),
1176            FragmentList {
1177                fragments: vec![WebUIFragment::raw("<item></item>")],
1178            },
1179        );
1180
1181        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1182        protocol
1183            .components
1184            .entry("mp-search-page".to_string())
1185            .or_default()
1186            .template = "<mp-search-page></mp-search-page>".to_string();
1187
1188        let (needed, _inv) =
1189            get_needed_components_for_request(&protocol, "index.html", "/search", "");
1190        let needed: HashSet<String> = needed.into_iter().collect();
1191
1192        assert!(needed.contains("mp-app"));
1193        assert!(needed.contains("mp-search-page"));
1194        assert!(needed.contains("mp-filter-panel"));
1195        assert!(needed.contains("mp-item-card"));
1196    }
1197
1198    #[test]
1199    fn test_get_needed_components_for_request_respects_inventory() {
1200        let mut fragments = HashMap::new();
1201        fragments.insert(
1202            "index.html".to_string(),
1203            FragmentList {
1204                fragments: vec![WebUIFragment::component("mp-app")],
1205            },
1206        );
1207        fragments.insert(
1208            "mp-app".to_string(),
1209            FragmentList {
1210                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1211                    path: "/search".into(),
1212                    fragment_id: "mp-search-page".into(),
1213                    exact: true,
1214                    ..Default::default()
1215                })],
1216            },
1217        );
1218        fragments.insert(
1219            "mp-search-page".to_string(),
1220            FragmentList {
1221                fragments: vec![WebUIFragment::component("mp-product-grid")],
1222            },
1223        );
1224        fragments.insert(
1225            "mp-product-grid".to_string(),
1226            FragmentList {
1227                fragments: vec![WebUIFragment::raw("<grid></grid>")],
1228            },
1229        );
1230
1231        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1232        for name in ["mp-app", "mp-search-page", "mp-product-grid"] {
1233            protocol
1234                .components
1235                .entry(name.to_string())
1236                .or_default()
1237                .template = format!("<f-template id=\"{name}\"></f-template>");
1238        }
1239
1240        let (_needed, inventory) =
1241            get_needed_components_for_request(&protocol, "index.html", "/search", "");
1242        let (needed_again, _) =
1243            get_needed_components_for_request(&protocol, "index.html", "/search", &inventory);
1244        assert!(needed_again.is_empty());
1245    }
1246
1247    #[test]
1248    fn test_render_partial_separates_module_styles_from_templates() {
1249        let mut fragments = HashMap::new();
1250        fragments.insert(
1251            "index.html".to_string(),
1252            FragmentList {
1253                fragments: vec![WebUIFragment::component("my-page")],
1254            },
1255        );
1256        fragments.insert(
1257            "my-page".to_string(),
1258            FragmentList {
1259                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1260            },
1261        );
1262
1263        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1264        let component = protocol
1265            .components
1266            .entry("my-page".to_string())
1267            .or_default();
1268        component.template =
1269            "(function(){window.__webui_templates['my-page']={h:'<p>page</p>'};})();".to_string();
1270        component.css = ".page{color:red}".to_string();
1271
1272        let partial = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1273
1274        // templateStyles should contain the separated module style tag
1275        let styles = partial["templateStyles"]
1276            .as_array()
1277            .expect("templateStyles should be an array");
1278        assert_eq!(styles.len(), 1);
1279        assert!(
1280            styles[0]
1281                .as_str()
1282                .unwrap_or_default()
1283                .contains("specifier=\"my-page\""),
1284            "module style should carry the component specifier"
1285        );
1286        assert!(
1287            styles[0]
1288                .as_str()
1289                .unwrap_or_default()
1290                .contains(".page{color:red}"),
1291            "module style should contain the CSS content"
1292        );
1293
1294        // templates should contain only the clean JS IIFE
1295        let templates = partial["templates"]
1296            .as_array()
1297            .expect("templates should be an array");
1298        assert_eq!(templates.len(), 1);
1299        assert!(
1300            !templates[0].as_str().unwrap_or_default().contains("<style"),
1301            "template should not contain any style tags"
1302        );
1303        assert!(
1304            templates[0]
1305                .as_str()
1306                .unwrap_or_default()
1307                .starts_with("(function()"),
1308            "template should be a raw JS IIFE"
1309        );
1310    }
1311
1312    #[test]
1313    fn test_render_partial_link_strategy_has_empty_template_styles() {
1314        // Link-strategy components have css_href but no css content.
1315        // templateStyles should be empty; templates should contain the IIFE.
1316        let mut fragments = HashMap::new();
1317        fragments.insert(
1318            "index.html".to_string(),
1319            FragmentList {
1320                fragments: vec![WebUIFragment::component("my-page")],
1321            },
1322        );
1323        fragments.insert(
1324            "my-page".to_string(),
1325            FragmentList {
1326                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1327            },
1328        );
1329
1330        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1331        let component = protocol
1332            .components
1333            .entry("my-page".to_string())
1334            .or_default();
1335        component.template = "(function(){})();".to_string();
1336        component.css_href = "/my-page.css".to_string();
1337        // css is empty — Link strategy stores href, not content
1338
1339        let partial = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1340        let styles = partial["templateStyles"]
1341            .as_array()
1342            .expect("templateStyles should be an array");
1343        let templates = partial["templates"]
1344            .as_array()
1345            .expect("templates should be an array");
1346
1347        assert!(
1348            styles.is_empty(),
1349            "Link strategy should produce empty templateStyles: {styles:?}"
1350        );
1351        assert_eq!(templates.len(), 1, "should include the template IIFE");
1352    }
1353
1354    #[test]
1355    fn test_render_partial_style_strategy_has_empty_template_styles() {
1356        // Style-strategy components have CSS embedded in the template HTML
1357        // (as <style>...</style>), not in component.css.
1358        // templateStyles should be empty; templates should contain the IIFE.
1359        let mut fragments = HashMap::new();
1360        fragments.insert(
1361            "index.html".to_string(),
1362            FragmentList {
1363                fragments: vec![WebUIFragment::component("my-page")],
1364            },
1365        );
1366        fragments.insert(
1367            "my-page".to_string(),
1368            FragmentList {
1369                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1370            },
1371        );
1372
1373        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1374        let component = protocol
1375            .components
1376            .entry("my-page".to_string())
1377            .or_default();
1378        // Style strategy: CSS is inside the template HTML, not in component.css
1379        component.template =
1380            "(function(){var w=window.__webui_templates;w['my-page']={h:'<style>.p{color:red}</style><p>page</p>'};})();"
1381                .to_string();
1382        // css is empty for Style strategy
1383
1384        let partial = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1385        let styles = partial["templateStyles"]
1386            .as_array()
1387            .expect("templateStyles should be an array");
1388        let templates = partial["templates"]
1389            .as_array()
1390            .expect("templates should be an array");
1391
1392        assert!(
1393            styles.is_empty(),
1394            "Style strategy should produce empty templateStyles: {styles:?}"
1395        );
1396        assert_eq!(templates.len(), 1, "should include the template IIFE");
1397        assert!(
1398            templates[0]
1399                .as_str()
1400                .unwrap_or_default()
1401                .contains("<style>"),
1402            "Style strategy template should contain inline <style> tag"
1403        );
1404    }
1405
1406    #[test]
1407    fn test_render_partial_empty_styles_for_no_css_components() {
1408        let mut fragments = HashMap::new();
1409        fragments.insert(
1410            "index.html".to_string(),
1411            FragmentList {
1412                fragments: vec![WebUIFragment::component("my-page")],
1413            },
1414        );
1415        fragments.insert(
1416            "my-page".to_string(),
1417            FragmentList {
1418                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1419            },
1420        );
1421
1422        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1423        let component = protocol
1424            .components
1425            .entry("my-page".to_string())
1426            .or_default();
1427        component.template = "(function(){})();".to_string();
1428        // No CSS — simulates Link or Style mode
1429
1430        let partial = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1431        let styles = partial["templateStyles"]
1432            .as_array()
1433            .expect("templateStyles should be an array");
1434        assert!(
1435            styles.is_empty(),
1436            "templateStyles should be empty when components have no CSS"
1437        );
1438    }
1439
1440    #[test]
1441    fn test_render_partial_sends_styles_even_when_templates_filtered_by_inventory() {
1442        let mut fragments = HashMap::new();
1443        fragments.insert(
1444            "index.html".to_string(),
1445            FragmentList {
1446                fragments: vec![WebUIFragment::component("my-page")],
1447            },
1448        );
1449        fragments.insert(
1450            "my-page".to_string(),
1451            FragmentList {
1452                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1453            },
1454        );
1455
1456        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1457        let component = protocol
1458            .components
1459            .entry("my-page".to_string())
1460            .or_default();
1461        component.template = "(function(){})();".to_string();
1462        component.css = ".page{color:red}".to_string();
1463
1464        // First call to establish inventory
1465        let partial1 = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1466        let inv = partial1["inventory"].as_str().unwrap_or_default();
1467        assert!(!inv.is_empty());
1468
1469        // Second call with the inventory — both templates and styles should be empty
1470        // because the inventory covers this component.  The SSR handler emits all
1471        // module style definitions in <head> for inventoried components, so the
1472        // client already has the CSS definition.
1473        let partial2 = render_partial(&protocol, serde_json::json!({}), "index.html", "/", inv);
1474        let styles = partial2["templateStyles"]
1475            .as_array()
1476            .expect("templateStyles should be an array");
1477        let templates = partial2["templates"]
1478            .as_array()
1479            .expect("templates should be an array");
1480
1481        assert!(
1482            templates.is_empty(),
1483            "templates should be empty when inventory is full"
1484        );
1485        assert!(
1486            styles.is_empty(),
1487            "module styles should be empty when inventory is full — SSR already placed them"
1488        );
1489    }
1490
1491    #[test]
1492    fn test_non_route_siblings_included_in_needed_components() {
1493        // Reproduces the commerce app layout: mp-app has both route children
1494        // (via outlet) AND non-route sibling components (mp-cart-panel, mp-footer).
1495        // All siblings must be in the needed set so their module CSS definitions
1496        // are emitted in <head> during SSR.
1497        let mut fragments = HashMap::new();
1498
1499        // Entry → app-shell component
1500        fragments.insert(
1501            "index.html".to_string(),
1502            FragmentList {
1503                fragments: vec![WebUIFragment::component("app-shell")],
1504            },
1505        );
1506
1507        // app-shell has: navbar (component), route (outlet), cart-panel (component)
1508        fragments.insert(
1509            "app-shell".to_string(),
1510            FragmentList {
1511                fragments: vec![
1512                    WebUIFragment::component("my-navbar"),
1513                    WebUIFragment::route_from(WebUiFragmentRoute {
1514                        path: "/about".into(),
1515                        fragment_id: "page-about".into(),
1516                        exact: true,
1517                        ..Default::default()
1518                    }),
1519                    WebUIFragment::component("cart-panel"),
1520                ],
1521            },
1522        );
1523
1524        // Leaf fragments
1525        fragments.insert(
1526            "my-navbar".to_string(),
1527            FragmentList {
1528                fragments: vec![WebUIFragment::raw("<nav/>")],
1529            },
1530        );
1531        fragments.insert(
1532            "page-about".to_string(),
1533            FragmentList {
1534                fragments: vec![WebUIFragment::raw("<h1>About</h1>")],
1535            },
1536        );
1537        fragments.insert(
1538            "cart-panel".to_string(),
1539            FragmentList {
1540                fragments: vec![WebUIFragment::raw("<aside>Cart</aside>")],
1541            },
1542        );
1543
1544        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1545        for name in ["app-shell", "my-navbar", "page-about", "cart-panel"] {
1546            let comp = protocol.components.entry(name.to_string()).or_default();
1547            comp.template = format!("(function(){{/* {name} */}})();");
1548            comp.css = format!(".{name}{{display:block}}");
1549        }
1550
1551        let (needed, _inv) =
1552            get_needed_components_for_request(&protocol, "index.html", "/about", "");
1553
1554        assert!(
1555            needed.contains(&"app-shell".to_string()),
1556            "app-shell should be needed: {needed:?}"
1557        );
1558        assert!(
1559            needed.contains(&"my-navbar".to_string()),
1560            "my-navbar (non-route sibling) should be needed: {needed:?}"
1561        );
1562        assert!(
1563            needed.contains(&"page-about".to_string()),
1564            "page-about (active route) should be needed: {needed:?}"
1565        );
1566        assert!(
1567            needed.contains(&"cart-panel".to_string()),
1568            "cart-panel (non-route sibling) should be needed: {needed:?}"
1569        );
1570    }
1571
1572    // ── RouteChainEntry.to_json + allowed_query tests ────────────────
1573
1574    #[test]
1575    fn test_chain_entry_to_json_includes_allowed_query() {
1576        let entry = RouteChainEntry {
1577            component: "compose-page".into(),
1578            path: "compose".into(),
1579            params: HashMap::new(),
1580            exact: true,
1581            allowed_query: "action,to,subject".into(),
1582        };
1583        let json = entry.to_json();
1584        assert_eq!(json["allowedQuery"], "action,to,subject");
1585    }
1586
1587    #[test]
1588    fn test_chain_entry_to_json_omits_empty_allowed_query() {
1589        let entry = RouteChainEntry {
1590            component: "home-page".into(),
1591            path: "/".into(),
1592            params: HashMap::new(),
1593            exact: true,
1594            allowed_query: String::new(),
1595        };
1596        let json = entry.to_json();
1597        assert!(
1598            json.get("allowedQuery").is_none(),
1599            "empty allowed_query should not appear in JSON"
1600        );
1601    }
1602
1603    #[test]
1604    fn test_collect_route_chain_carries_allowed_query() {
1605        let mut fragments = HashMap::new();
1606        fragments.insert(
1607            "index.html".to_string(),
1608            FragmentList {
1609                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1610                    path: "/".into(),
1611                    fragment_id: "app-shell".into(),
1612                    exact: false,
1613                    children: vec![WebUiFragmentRoute {
1614                        path: "compose".into(),
1615                        fragment_id: "compose-page".into(),
1616                        exact: true,
1617                        allowed_query: "action,to".into(),
1618                        ..Default::default()
1619                    }],
1620                    ..Default::default()
1621                })],
1622            },
1623        );
1624        fragments.insert(
1625            "app-shell".to_string(),
1626            FragmentList {
1627                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
1628            },
1629        );
1630        fragments.insert(
1631            "compose-page".to_string(),
1632            FragmentList {
1633                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
1634            },
1635        );
1636        let protocol = WebUIProtocol::new(fragments);
1637
1638        let chain = collect_route_chain(&protocol, "index.html", "/compose");
1639        assert_eq!(chain.len(), 2);
1640        assert_eq!(chain[0].component, "app-shell");
1641        assert!(chain[0].allowed_query.is_empty());
1642        assert_eq!(chain[1].component, "compose-page");
1643        assert_eq!(chain[1].allowed_query, "action,to");
1644    }
1645
1646    #[test]
1647    fn test_render_component_templates_returns_template_and_css() {
1648        let mut fragments = HashMap::new();
1649        fragments.insert(
1650            "settings-dialog".to_string(),
1651            FragmentList {
1652                fragments: vec![WebUIFragment::raw("<div class='dialog'>Settings</div>")],
1653            },
1654        );
1655        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1656        let comp = protocol
1657            .components
1658            .entry("settings-dialog".to_string())
1659            .or_default();
1660        comp.template = "(function(){window.__webui_templates['settings-dialog']={h:'<div>Settings</div>'};})();".to_string();
1661        comp.css = ".dialog{position:fixed}".to_string();
1662
1663        let result = render_component_templates(&protocol, &["settings-dialog"], "");
1664        let templates = result["templates"].as_array().expect("templates array");
1665        let styles = result["templateStyles"].as_array().expect("styles array");
1666
1667        assert_eq!(templates.len(), 1);
1668        assert!(templates[0].as_str().unwrap().contains("settings-dialog"));
1669        assert_eq!(styles.len(), 1);
1670        assert!(styles[0]
1671            .as_str()
1672            .unwrap()
1673            .contains(".dialog{position:fixed}"));
1674    }
1675
1676    #[test]
1677    fn test_render_component_templates_respects_inventory() {
1678        let mut fragments = HashMap::new();
1679        fragments.insert(
1680            "my-dialog".to_string(),
1681            FragmentList {
1682                fragments: vec![WebUIFragment::raw("<div>Dialog</div>")],
1683            },
1684        );
1685        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1686        let comp = protocol
1687            .components
1688            .entry("my-dialog".to_string())
1689            .or_default();
1690        comp.template = "(function(){})();".to_string();
1691        comp.css = ".d{color:red}".to_string();
1692
1693        // First call: no inventory → should return the component
1694        let result1 = render_component_templates(&protocol, &["my-dialog"], "");
1695        let inv = result1["inventory"].as_str().expect("inventory string");
1696        assert_eq!(result1["templates"].as_array().unwrap().len(), 1);
1697
1698        // Second call with inventory → component already loaded, should skip
1699        let result2 = render_component_templates(&protocol, &["my-dialog"], inv);
1700        assert_eq!(result2["templates"].as_array().unwrap().len(), 0);
1701        assert_eq!(result2["templateStyles"].as_array().unwrap().len(), 0);
1702    }
1703
1704    #[test]
1705    fn test_render_component_templates_unknown_component_returns_empty() {
1706        let fragments = HashMap::new();
1707        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1708
1709        let result = render_component_templates(&protocol, &["nonexistent-widget"], "");
1710        assert_eq!(result["templates"].as_array().unwrap().len(), 0);
1711        assert_eq!(result["templateStyles"].as_array().unwrap().len(), 0);
1712    }
1713}