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