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