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