Skip to main content

webui_handler/
route_handler.rs

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