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            let mut s = String::with_capacity(40 + tag.len() + component.css.len());
1033            s.push_str("<style type=\"module\" specifier=\"");
1034            s.push_str(tag);
1035            s.push_str("\">");
1036            s.push_str(&component.css);
1037            s.push_str("</style>");
1038            style_array.push(Value::String(s));
1039        }
1040        tmpl_array.push(Value::String(component.template.clone()));
1041    }
1042
1043    (style_array, tmpl_array)
1044}
1045
1046// ── Route Chain ─────────────────────────────────────────────────────
1047
1048/// A single entry in the matched route chain, one per nesting level.
1049#[derive(Debug, Clone)]
1050pub struct RouteChainEntry {
1051    /// Component tag name for this route level.
1052    pub component: String,
1053    /// The route path pattern (as declared in the template).
1054    pub path: String,
1055    /// Bound route parameters at this level.
1056    pub params: HashMap<String, String>,
1057    /// Whether this route requires an exact match.
1058    pub exact: bool,
1059    /// Comma-separated allowlist of query parameters forwarded as attributes.
1060    pub allowed_query: String,
1061    /// When true, the router keeps the component alive across navigations.
1062    pub keep_alive: bool,
1063    /// Cache tag templates from the proto (e.g. `["thread:{threadId}", "inbox"]`).
1064    pub cache_tags: Vec<String>,
1065    /// Invalidation tag templates from the proto.
1066    pub invalidates: Vec<String>,
1067    /// Component tag name for pending/loading UI.
1068    pub pending_component: String,
1069    /// Component tag name for error boundary UI.
1070    pub error_component: String,
1071}
1072
1073impl RouteChainEntry {
1074    /// Serialize this entry to a JSON value ready for inclusion in a partial response.
1075    #[must_use]
1076    pub fn to_json(&self) -> Value {
1077        let mut obj = serde_json::Map::with_capacity(9);
1078        obj.insert("component".into(), Value::String(self.component.clone()));
1079        obj.insert("path".into(), Value::String(self.path.clone()));
1080        if !self.params.is_empty() {
1081            let params_obj: serde_json::Map<String, Value> = self
1082                .params
1083                .iter()
1084                .map(|(k, v)| (k.clone(), Value::String(v.clone())))
1085                .collect();
1086            obj.insert("params".into(), Value::Object(params_obj));
1087        }
1088        if self.exact {
1089            obj.insert("exact".into(), Value::Bool(true));
1090        }
1091        if !self.allowed_query.is_empty() {
1092            obj.insert(
1093                "allowedQuery".into(),
1094                Value::String(self.allowed_query.clone()),
1095            );
1096        }
1097        if self.keep_alive {
1098            obj.insert("keepAlive".into(), Value::Bool(true));
1099        }
1100        if !self.pending_component.is_empty() {
1101            obj.insert(
1102                "pendingComponent".into(),
1103                Value::String(self.pending_component.clone()),
1104            );
1105        }
1106        if !self.error_component.is_empty() {
1107            obj.insert(
1108                "errorComponent".into(),
1109                Value::String(self.error_component.clone()),
1110            );
1111        }
1112        if !self.invalidates.is_empty() {
1113            obj.insert(
1114                "invalidates".into(),
1115                Value::Array(
1116                    self.invalidates
1117                        .iter()
1118                        .map(|s| Value::String(s.clone()))
1119                        .collect(),
1120                ),
1121            );
1122        }
1123        Value::Object(obj)
1124    }
1125}
1126
1127/// Collect the matched route chain for a request path.
1128///
1129/// Walks the fragment graph from `entry_id`, follows the matched route at
1130/// each nesting level, and returns a chain entry per matched level.
1131pub fn collect_route_chain(
1132    protocol: &WebUIProtocol,
1133    entry_id: &str,
1134    request_path: &str,
1135    cache: &mut CompiledRouteCache,
1136) -> Vec<RouteChainEntry> {
1137    let mut chain = Vec::new();
1138    let mut visited_fragments = HashSet::new();
1139    let mut stack = vec![QueuedFragment {
1140        id: entry_id.to_string(),
1141        inventoryable: false,
1142        route_base: "/".to_string(),
1143    }];
1144
1145    while let Some(queued) = stack.pop() {
1146        if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
1147            continue;
1148        }
1149
1150        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1151            continue;
1152        };
1153
1154        let matched_route = route_renderer::find_best_route_match(
1155            &frag_list.fragments,
1156            request_path,
1157            &queued.route_base,
1158            cache,
1159        );
1160
1161        for frag in &frag_list.fragments {
1162            match frag.fragment.as_ref() {
1163                Some(Fragment::Component(component)) => {
1164                    stack.push(QueuedFragment {
1165                        id: component.fragment_id.clone(),
1166                        inventoryable: false,
1167                        route_base: queued.route_base.clone(),
1168                    });
1169                }
1170                Some(Fragment::Route(route_frag)) => {
1171                    let is_selected = matched_route
1172                        .as_ref()
1173                        .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1174                    if is_selected && !route_frag.fragment_id.is_empty() {
1175                        if let Some((_, ref rm)) = matched_route {
1176                            chain.push(RouteChainEntry {
1177                                component: route_frag.fragment_id.clone(),
1178                                path: route_frag.path.clone(),
1179                                params: rm.params.clone(),
1180                                exact: route_frag.exact,
1181                                allowed_query: route_frag.allowed_query.clone(),
1182                                keep_alive: route_frag.keep_alive,
1183                                cache_tags: route_frag.cache_tags.clone(),
1184                                invalidates: route_frag.invalidates.clone(),
1185                                pending_component: route_frag.pending_component.clone(),
1186                                error_component: route_frag.error_component.clone(),
1187                            });
1188
1189                            let child_route_base = route_matcher::compute_route_base(
1190                                request_path,
1191                                rm.consumed_segments,
1192                            );
1193
1194                            stack.push(QueuedFragment {
1195                                id: route_frag.fragment_id.clone(),
1196                                inventoryable: false,
1197                                route_base: child_route_base.clone(),
1198                            });
1199
1200                            // Walk nested children iteratively
1201                            collect_chain_from_children(
1202                                &route_frag.children,
1203                                request_path,
1204                                &child_route_base,
1205                                &mut chain,
1206                                cache,
1207                            );
1208                        }
1209                    }
1210                }
1211                _ => {}
1212            }
1213        }
1214    }
1215
1216    chain
1217}
1218
1219/// Iteratively collect chain entries from nested route children.
1220fn collect_chain_from_children(
1221    children: &[WebUIFragmentRoute],
1222    request_path: &str,
1223    route_base: &str,
1224    chain: &mut Vec<RouteChainEntry>,
1225    cache: &mut CompiledRouteCache,
1226) {
1227    let mut pending: Vec<(&[WebUIFragmentRoute], String)> =
1228        vec![(children, route_base.to_string())];
1229
1230    while let Some((current, base)) = pending.pop() {
1231        if let Some((idx, rm)) = select_best_child_route(current, request_path, &base, cache) {
1232            let matched = &current[idx];
1233            chain.push(RouteChainEntry {
1234                component: matched.fragment_id.clone(),
1235                path: matched.path.clone(),
1236                params: rm.params,
1237                exact: matched.exact,
1238                allowed_query: matched.allowed_query.clone(),
1239                keep_alive: matched.keep_alive,
1240                cache_tags: matched.cache_tags.clone(),
1241                invalidates: matched.invalidates.clone(),
1242                pending_component: matched.pending_component.clone(),
1243                error_component: matched.error_component.clone(),
1244            });
1245            if !matched.children.is_empty() {
1246                let child_base =
1247                    route_matcher::compute_route_base(request_path, rm.consumed_segments);
1248                pending.push((&matched.children, child_base));
1249            }
1250        }
1251    }
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256    use super::*;
1257    use std::collections::HashMap;
1258    use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
1259
1260    #[test]
1261    fn test_parse_encode_inventory_roundtrip() {
1262        let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
1263        let hex = encode_inventory(&original);
1264        let decoded = parse_inventory(&hex).unwrap();
1265        assert_eq!(original, decoded);
1266    }
1267
1268    #[test]
1269    fn test_parse_inventory_empty() {
1270        let result = parse_inventory("").unwrap();
1271        assert!(result.is_empty());
1272    }
1273
1274    #[test]
1275    fn test_parse_inventory_rejects_odd_length() {
1276        let result = parse_inventory("abc");
1277        assert!(result.is_err());
1278    }
1279
1280    #[test]
1281    fn test_parse_inventory_rejects_invalid_hex() {
1282        let result = parse_inventory("zz");
1283        assert!(result.is_err());
1284    }
1285
1286    #[test]
1287    fn test_bitfield_set_and_check() {
1288        let mut inv = vec![0u8; 4];
1289        set_component(&mut inv, 0);
1290        set_component(&mut inv, 7);
1291        set_component(&mut inv, 8);
1292        set_component(&mut inv, 15);
1293        assert!(has_component(&inv, 0));
1294        assert!(has_component(&inv, 7));
1295        assert!(has_component(&inv, 8));
1296        assert!(has_component(&inv, 15));
1297        assert!(!has_component(&inv, 1));
1298        assert!(!has_component(&inv, 9));
1299    }
1300
1301    #[test]
1302    fn test_filter_needed_components_no_false_positives() {
1303        // With sequential indices, no two components share a bit
1304        let mut index = HashMap::new();
1305        index.insert("email-message".to_string(), 0);
1306        index.insert("o-button".to_string(), 1);
1307        index.insert("o-avatar".to_string(), 2);
1308
1309        let mut names = HashSet::new();
1310        names.insert("email-message".to_string());
1311        names.insert("o-button".to_string());
1312
1313        // Only o-button (index 1) is loaded — bit 1 set
1314        let mut inv = vec![0u8; 1];
1315        set_component(&mut inv, 1); // o-button
1316        let inv_hex = encode_inventory(&inv);
1317
1318        let (needed, _) = filter_needed_components(&names, &inv_hex, &index).unwrap();
1319        assert_eq!(needed.len(), 1);
1320        assert!(
1321            needed.contains(&"email-message".to_string()),
1322            "email-message must be needed: {needed:?}"
1323        );
1324    }
1325
1326    #[test]
1327    fn test_get_needed_components_empty_inventory() {
1328        let mut fragments = HashMap::new();
1329        fragments.insert(
1330            "app-shell".to_string(),
1331            FragmentList {
1332                fragments: vec![WebUIFragment::component("my-card")],
1333            },
1334        );
1335        fragments.insert(
1336            "my-card".to_string(),
1337            FragmentList {
1338                fragments: vec![WebUIFragment::raw("card")],
1339            },
1340        );
1341
1342        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1343        let comp_index = build_component_index(&protocol);
1344        let (needed, _inv) =
1345            get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
1346        assert!(needed.contains(&"app-shell".to_string()));
1347        assert!(needed.contains(&"my-card".to_string()));
1348    }
1349
1350    #[test]
1351    fn test_get_needed_components_skips_inventory() {
1352        let mut fragments = HashMap::new();
1353        fragments.insert(
1354            "app-shell".to_string(),
1355            FragmentList {
1356                fragments: vec![WebUIFragment::component("my-card")],
1357            },
1358        );
1359        fragments.insert(
1360            "my-card".to_string(),
1361            FragmentList {
1362                fragments: vec![WebUIFragment::raw("card")],
1363            },
1364        );
1365
1366        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1367        protocol
1368            .components
1369            .entry("app-shell".to_string())
1370            .or_default()
1371            .template = "<t></t>".to_string();
1372        protocol
1373            .components
1374            .entry("my-card".to_string())
1375            .or_default()
1376            .template = "<t></t>".to_string();
1377
1378        let comp_index = build_component_index(&protocol);
1379        let (_needed, inv_hex) =
1380            get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
1381        let (needed2, _) =
1382            get_needed_components(&protocol, "app-shell", &inv_hex, &comp_index).unwrap();
1383        assert!(needed2.is_empty());
1384    }
1385
1386    #[test]
1387    fn test_get_needed_components_skips_control_fragments() {
1388        let mut fragments = HashMap::new();
1389        fragments.insert(
1390            "app-shell".to_string(),
1391            FragmentList {
1392                fragments: vec![
1393                    WebUIFragment::if_cond(
1394                        webui_protocol::ConditionExpr::identifier("showNav"),
1395                        "if-shell",
1396                    ),
1397                    WebUIFragment::for_loop("item", "items", "for-items"),
1398                    WebUIFragment::attribute_template("title", "attr-title"),
1399                ],
1400            },
1401        );
1402        fragments.insert(
1403            "if-shell".to_string(),
1404            FragmentList {
1405                fragments: vec![WebUIFragment::component("mp-category-nav")],
1406            },
1407        );
1408        fragments.insert(
1409            "for-items".to_string(),
1410            FragmentList {
1411                fragments: vec![WebUIFragment::component("mp-product-card")],
1412            },
1413        );
1414        fragments.insert(
1415            "attr-title".to_string(),
1416            FragmentList {
1417                fragments: vec![WebUIFragment::raw("Products")],
1418            },
1419        );
1420        fragments.insert(
1421            "mp-category-nav".to_string(),
1422            FragmentList {
1423                fragments: vec![WebUIFragment::raw("<nav></nav>")],
1424            },
1425        );
1426        fragments.insert(
1427            "mp-product-card".to_string(),
1428            FragmentList {
1429                fragments: vec![WebUIFragment::raw("<article></article>")],
1430            },
1431        );
1432
1433        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1434        let comp_index = build_component_index(&protocol);
1435        let needed: HashSet<String> =
1436            get_needed_components(&protocol, "app-shell", "", &comp_index)
1437                .unwrap()
1438                .0
1439                .into_iter()
1440                .collect();
1441
1442        assert!(needed.contains("app-shell"));
1443        assert!(needed.contains("mp-category-nav"));
1444        assert!(needed.contains("mp-product-card"));
1445        assert!(!needed.contains("if-shell"));
1446        assert!(!needed.contains("for-items"));
1447        assert!(!needed.contains("attr-title"));
1448    }
1449
1450    #[test]
1451    fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
1452        let mut fragments = HashMap::new();
1453        fragments.insert(
1454            "index.html".to_string(),
1455            FragmentList {
1456                fragments: vec![WebUIFragment::component("mp-app")],
1457            },
1458        );
1459        fragments.insert(
1460            "mp-app".to_string(),
1461            FragmentList {
1462                fragments: vec![
1463                    WebUIFragment::component("mp-category-nav"),
1464                    WebUIFragment::route_from(WebUiFragmentRoute {
1465                        path: "/search/:category".into(),
1466                        fragment_id: "mp-search-page".into(),
1467                        exact: true,
1468                        keep_alive: false,
1469                        ..Default::default()
1470                    }),
1471                    WebUIFragment::route_from(WebUiFragmentRoute {
1472                        path: "/product/:handle".into(),
1473                        fragment_id: "mp-product-page".into(),
1474                        exact: true,
1475                        keep_alive: false,
1476                        ..Default::default()
1477                    }),
1478                ],
1479            },
1480        );
1481        fragments.insert(
1482            "mp-category-nav".to_string(),
1483            FragmentList {
1484                fragments: vec![WebUIFragment::raw("<nav></nav>")],
1485            },
1486        );
1487        fragments.insert(
1488            "mp-search-page".to_string(),
1489            FragmentList {
1490                fragments: vec![WebUIFragment::component("mp-product-grid")],
1491            },
1492        );
1493        fragments.insert(
1494            "mp-product-grid".to_string(),
1495            FragmentList {
1496                fragments: vec![WebUIFragment::raw("<div>grid</div>")],
1497            },
1498        );
1499        fragments.insert(
1500            "mp-product-page".to_string(),
1501            FragmentList {
1502                fragments: vec![WebUIFragment::component("mp-product-detail")],
1503            },
1504        );
1505        fragments.insert(
1506            "mp-product-detail".to_string(),
1507            FragmentList {
1508                fragments: vec![WebUIFragment::raw("<div>detail</div>")],
1509            },
1510        );
1511
1512        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1513        protocol
1514            .components
1515            .entry("mp-search-page".to_string())
1516            .or_default()
1517            .template = "<mp-search-page></mp-search-page>".to_string();
1518        protocol
1519            .components
1520            .entry("mp-product-page".to_string())
1521            .or_default()
1522            .template = "<mp-product-page></mp-product-page>".to_string();
1523
1524        let comp_index = build_component_index(&protocol);
1525        let (needed, _inv) = get_needed_components_for_request(
1526            &protocol,
1527            "index.html",
1528            "/search/shirts",
1529            "",
1530            &comp_index,
1531        )
1532        .unwrap();
1533        let needed: HashSet<String> = needed.into_iter().collect();
1534
1535        assert!(needed.contains("mp-app"));
1536        assert!(needed.contains("mp-category-nav"));
1537        assert!(needed.contains("mp-search-page"));
1538        assert!(needed.contains("mp-product-grid"));
1539        assert!(!needed.contains("mp-product-page"));
1540        assert!(!needed.contains("mp-product-detail"));
1541    }
1542
1543    #[test]
1544    fn test_get_needed_components_for_request_follows_nested_route_chain() {
1545        let mut fragments = HashMap::new();
1546        fragments.insert(
1547            "index.html".to_string(),
1548            FragmentList {
1549                fragments: vec![WebUIFragment::component("mp-app")],
1550            },
1551        );
1552        fragments.insert(
1553            "mp-app".to_string(),
1554            FragmentList {
1555                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1556                    path: "/account/*rest".into(),
1557                    fragment_id: "mp-account-shell".into(),
1558                    exact: false,
1559                    keep_alive: false,
1560                    ..Default::default()
1561                })],
1562            },
1563        );
1564        fragments.insert(
1565            "mp-account-shell".to_string(),
1566            FragmentList {
1567                fragments: vec![
1568                    WebUIFragment::component("mp-account-nav"),
1569                    WebUIFragment::route_from(WebUiFragmentRoute {
1570                        path: "/account/profile".into(),
1571                        fragment_id: "mp-profile-page".into(),
1572                        exact: true,
1573                        keep_alive: false,
1574                        ..Default::default()
1575                    }),
1576                    WebUIFragment::route_from(WebUiFragmentRoute {
1577                        path: "/account/orders/:id".into(),
1578                        fragment_id: "mp-order-page".into(),
1579                        exact: true,
1580                        keep_alive: false,
1581                        ..Default::default()
1582                    }),
1583                ],
1584            },
1585        );
1586        fragments.insert(
1587            "mp-account-nav".to_string(),
1588            FragmentList {
1589                fragments: vec![WebUIFragment::raw("<nav></nav>")],
1590            },
1591        );
1592        fragments.insert(
1593            "mp-profile-page".to_string(),
1594            FragmentList {
1595                fragments: vec![WebUIFragment::raw("<profile></profile>")],
1596            },
1597        );
1598        fragments.insert(
1599            "mp-order-page".to_string(),
1600            FragmentList {
1601                fragments: vec![WebUIFragment::component("mp-order-detail")],
1602            },
1603        );
1604        fragments.insert(
1605            "mp-order-detail".to_string(),
1606            FragmentList {
1607                fragments: vec![WebUIFragment::raw("<detail></detail>")],
1608            },
1609        );
1610
1611        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1612        protocol
1613            .components
1614            .entry("mp-account-shell".to_string())
1615            .or_default()
1616            .template = "<mp-account-shell></mp-account-shell>".to_string();
1617        protocol
1618            .components
1619            .entry("mp-profile-page".to_string())
1620            .or_default()
1621            .template = "<mp-profile-page></mp-profile-page>".to_string();
1622        protocol
1623            .components
1624            .entry("mp-order-page".to_string())
1625            .or_default()
1626            .template = "<mp-order-page></mp-order-page>".to_string();
1627
1628        let comp_index = build_component_index(&protocol);
1629        let (needed, _inv) = get_needed_components_for_request(
1630            &protocol,
1631            "index.html",
1632            "/account/orders/42",
1633            "",
1634            &comp_index,
1635        )
1636        .unwrap();
1637        let needed: HashSet<String> = needed.into_iter().collect();
1638
1639        assert!(needed.contains("mp-app"));
1640        assert!(needed.contains("mp-account-shell"));
1641        assert!(needed.contains("mp-account-nav"));
1642        assert!(needed.contains("mp-order-page"));
1643        assert!(needed.contains("mp-order-detail"));
1644        assert!(!needed.contains("mp-profile-page"));
1645    }
1646
1647    #[test]
1648    fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
1649        let mut fragments = HashMap::new();
1650        fragments.insert(
1651            "index.html".to_string(),
1652            FragmentList {
1653                fragments: vec![WebUIFragment::component("mp-app")],
1654            },
1655        );
1656        fragments.insert(
1657            "mp-app".to_string(),
1658            FragmentList {
1659                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1660                    path: "/search".into(),
1661                    fragment_id: "mp-search-page".into(),
1662                    exact: true,
1663                    keep_alive: false,
1664                    ..Default::default()
1665                })],
1666            },
1667        );
1668        fragments.insert(
1669            "mp-search-page".to_string(),
1670            FragmentList {
1671                fragments: vec![
1672                    WebUIFragment::if_cond(
1673                        webui_protocol::ConditionExpr::identifier("showFilters"),
1674                        "if-filters",
1675                    ),
1676                    WebUIFragment::for_loop("item", "items", "item-loop"),
1677                ],
1678            },
1679        );
1680        fragments.insert(
1681            "if-filters".to_string(),
1682            FragmentList {
1683                fragments: vec![WebUIFragment::component("mp-filter-panel")],
1684            },
1685        );
1686        fragments.insert(
1687            "item-loop".to_string(),
1688            FragmentList {
1689                fragments: vec![WebUIFragment::component("mp-item-card")],
1690            },
1691        );
1692        fragments.insert(
1693            "mp-filter-panel".to_string(),
1694            FragmentList {
1695                fragments: vec![WebUIFragment::raw("<filters></filters>")],
1696            },
1697        );
1698        fragments.insert(
1699            "mp-item-card".to_string(),
1700            FragmentList {
1701                fragments: vec![WebUIFragment::raw("<item></item>")],
1702            },
1703        );
1704
1705        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1706        protocol
1707            .components
1708            .entry("mp-search-page".to_string())
1709            .or_default()
1710            .template = "<mp-search-page></mp-search-page>".to_string();
1711
1712        let comp_index = build_component_index(&protocol);
1713        let (needed, _inv) =
1714            get_needed_components_for_request(&protocol, "index.html", "/search", "", &comp_index)
1715                .unwrap();
1716        let needed: HashSet<String> = needed.into_iter().collect();
1717
1718        assert!(needed.contains("mp-app"));
1719        assert!(needed.contains("mp-search-page"));
1720        assert!(needed.contains("mp-filter-panel"));
1721        assert!(needed.contains("mp-item-card"));
1722    }
1723
1724    #[test]
1725    fn test_get_needed_components_for_request_respects_inventory() {
1726        let mut fragments = HashMap::new();
1727        fragments.insert(
1728            "index.html".to_string(),
1729            FragmentList {
1730                fragments: vec![WebUIFragment::component("mp-app")],
1731            },
1732        );
1733        fragments.insert(
1734            "mp-app".to_string(),
1735            FragmentList {
1736                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1737                    path: "/search".into(),
1738                    fragment_id: "mp-search-page".into(),
1739                    exact: true,
1740                    keep_alive: false,
1741                    ..Default::default()
1742                })],
1743            },
1744        );
1745        fragments.insert(
1746            "mp-search-page".to_string(),
1747            FragmentList {
1748                fragments: vec![WebUIFragment::component("mp-product-grid")],
1749            },
1750        );
1751        fragments.insert(
1752            "mp-product-grid".to_string(),
1753            FragmentList {
1754                fragments: vec![WebUIFragment::raw("<grid></grid>")],
1755            },
1756        );
1757
1758        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1759        for name in ["mp-app", "mp-search-page", "mp-product-grid"] {
1760            protocol
1761                .components
1762                .entry(name.to_string())
1763                .or_default()
1764                .template = format!("<f-template id=\"{name}\"></f-template>");
1765        }
1766
1767        let comp_index = build_component_index(&protocol);
1768        let (_needed, inventory) =
1769            get_needed_components_for_request(&protocol, "index.html", "/search", "", &comp_index)
1770                .unwrap();
1771        let (needed_again, _) = get_needed_components_for_request(
1772            &protocol,
1773            "index.html",
1774            "/search",
1775            &inventory,
1776            &comp_index,
1777        )
1778        .unwrap();
1779        assert!(needed_again.is_empty());
1780    }
1781
1782    #[test]
1783    fn test_render_partial_separates_module_styles_from_templates() {
1784        let mut fragments = HashMap::new();
1785        fragments.insert(
1786            "index.html".to_string(),
1787            FragmentList {
1788                fragments: vec![WebUIFragment::component("my-page")],
1789            },
1790        );
1791        fragments.insert(
1792            "my-page".to_string(),
1793            FragmentList {
1794                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1795            },
1796        );
1797
1798        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1799        let component = protocol
1800            .components
1801            .entry("my-page".to_string())
1802            .or_default();
1803        component.template =
1804            "(function(){window.__webui.templates['my-page']={h:'<p>page</p>'};})();".to_string();
1805        component.css = ".page{color:red}".to_string();
1806
1807        let mut index = ProtocolIndex::new(&protocol);
1808        let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
1809        let styles = partial["templateStyles"]
1810            .as_array()
1811            .expect("templateStyles should be an array");
1812        assert_eq!(styles.len(), 1);
1813        assert!(
1814            styles[0]
1815                .as_str()
1816                .unwrap_or_default()
1817                .contains("specifier=\"my-page\""),
1818            "module style should carry the component specifier"
1819        );
1820        assert!(
1821            styles[0]
1822                .as_str()
1823                .unwrap_or_default()
1824                .contains(".page{color:red}"),
1825            "module style should contain the CSS content"
1826        );
1827
1828        // templates should contain only the clean JS IIFE
1829        let templates = partial["templates"]
1830            .as_array()
1831            .expect("templates should be an array");
1832        assert_eq!(templates.len(), 1);
1833        assert!(
1834            !templates[0].as_str().unwrap_or_default().contains("<style"),
1835            "template should not contain any style tags"
1836        );
1837        assert!(
1838            templates[0]
1839                .as_str()
1840                .unwrap_or_default()
1841                .starts_with("(function()"),
1842            "template should be a raw JS IIFE"
1843        );
1844    }
1845
1846    #[test]
1847    fn test_render_partial_link_strategy_has_empty_template_styles() {
1848        // Link-strategy components have css_href but no css content.
1849        // templateStyles should be empty; templates should contain the IIFE.
1850        let mut fragments = HashMap::new();
1851        fragments.insert(
1852            "index.html".to_string(),
1853            FragmentList {
1854                fragments: vec![WebUIFragment::component("my-page")],
1855            },
1856        );
1857        fragments.insert(
1858            "my-page".to_string(),
1859            FragmentList {
1860                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1861            },
1862        );
1863
1864        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1865        let component = protocol
1866            .components
1867            .entry("my-page".to_string())
1868            .or_default();
1869        component.template = "(function(){})();".to_string();
1870        component.css_href = "my-page.css".to_string();
1871        // css is empty — Link strategy stores href, not content
1872
1873        let mut index = ProtocolIndex::new(&protocol);
1874        let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
1875        let styles = partial["templateStyles"]
1876            .as_array()
1877            .expect("templateStyles should be an array");
1878        let templates = partial["templates"]
1879            .as_array()
1880            .expect("templates should be an array");
1881
1882        assert!(
1883            styles.is_empty(),
1884            "Link strategy should produce empty templateStyles: {styles:?}"
1885        );
1886        assert_eq!(templates.len(), 1, "should include the template IIFE");
1887    }
1888
1889    #[test]
1890    fn test_render_partial_style_strategy_has_empty_template_styles() {
1891        // Style-strategy components have CSS embedded in the template HTML
1892        // (as <style>...</style>), not in component.css.
1893        // templateStyles should be empty; templates should contain the IIFE.
1894        let mut fragments = HashMap::new();
1895        fragments.insert(
1896            "index.html".to_string(),
1897            FragmentList {
1898                fragments: vec![WebUIFragment::component("my-page")],
1899            },
1900        );
1901        fragments.insert(
1902            "my-page".to_string(),
1903            FragmentList {
1904                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1905            },
1906        );
1907
1908        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1909        let component = protocol
1910            .components
1911            .entry("my-page".to_string())
1912            .or_default();
1913        // Style strategy: CSS is inside the template HTML, not in component.css
1914        component.template =
1915            "(function(){var w=(window.__webui||(window.__webui={})).templates||(window.__webui.templates={});w['my-page']={h:'<style>.p{color:red}</style><p>page</p>'};})();"
1916                .to_string();
1917        // css is empty for Style strategy
1918
1919        let mut index = ProtocolIndex::new(&protocol);
1920        let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
1921        let styles = partial["templateStyles"]
1922            .as_array()
1923            .expect("templateStyles should be an array");
1924        let templates = partial["templates"]
1925            .as_array()
1926            .expect("templates should be an array");
1927
1928        assert!(
1929            styles.is_empty(),
1930            "Style strategy should produce empty templateStyles: {styles:?}"
1931        );
1932        assert_eq!(templates.len(), 1, "should include the template IIFE");
1933        assert!(
1934            templates[0]
1935                .as_str()
1936                .unwrap_or_default()
1937                .contains("<style>"),
1938            "Style strategy template should contain inline <style> tag"
1939        );
1940    }
1941
1942    #[test]
1943    fn test_render_partial_empty_styles_for_no_css_components() {
1944        let mut fragments = HashMap::new();
1945        fragments.insert(
1946            "index.html".to_string(),
1947            FragmentList {
1948                fragments: vec![WebUIFragment::component("my-page")],
1949            },
1950        );
1951        fragments.insert(
1952            "my-page".to_string(),
1953            FragmentList {
1954                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1955            },
1956        );
1957
1958        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1959        let component = protocol
1960            .components
1961            .entry("my-page".to_string())
1962            .or_default();
1963        component.template = "(function(){})();".to_string();
1964        // No CSS — simulates Link or Style mode
1965
1966        let mut index = ProtocolIndex::new(&protocol);
1967        let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
1968        let styles = partial["templateStyles"]
1969            .as_array()
1970            .expect("templateStyles should be an array");
1971        assert!(
1972            styles.is_empty(),
1973            "templateStyles should be empty when components have no CSS"
1974        );
1975    }
1976
1977    #[test]
1978    fn test_render_partial_sends_styles_even_when_templates_filtered_by_inventory() {
1979        let mut fragments = HashMap::new();
1980        fragments.insert(
1981            "index.html".to_string(),
1982            FragmentList {
1983                fragments: vec![WebUIFragment::component("my-page")],
1984            },
1985        );
1986        fragments.insert(
1987            "my-page".to_string(),
1988            FragmentList {
1989                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1990            },
1991        );
1992
1993        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1994        let component = protocol
1995            .components
1996            .entry("my-page".to_string())
1997            .or_default();
1998        component.template = "(function(){})();".to_string();
1999        component.css = ".page{color:red}".to_string();
2000
2001        let mut index = ProtocolIndex::new(&protocol);
2002        // First call to establish inventory
2003        let partial1 = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
2004        let inv = partial1["inventory"].as_str().unwrap_or_default();
2005        assert!(!inv.is_empty());
2006
2007        // Second call with the inventory — both templates and styles should be empty
2008        // because the inventory covers this component.  The SSR handler emits all
2009        // module style definitions in <head> for inventoried components, so the
2010        // client already has the CSS definition.
2011        let partial2 = render_partial(&protocol, "index.html", "/", inv, &mut index).unwrap();
2012        let styles = partial2["templateStyles"]
2013            .as_array()
2014            .expect("templateStyles should be an array");
2015        let templates = partial2["templates"]
2016            .as_array()
2017            .expect("templates should be an array");
2018
2019        assert!(
2020            templates.is_empty(),
2021            "templates should be empty when inventory is full"
2022        );
2023        assert!(
2024            styles.is_empty(),
2025            "module styles should be empty when inventory is full — SSR already placed them"
2026        );
2027    }
2028
2029    #[test]
2030    fn test_non_route_siblings_included_in_needed_components() {
2031        // Reproduces the commerce app layout: mp-app has both route children
2032        // (via outlet) AND non-route sibling components (mp-cart-panel, mp-footer).
2033        // All siblings must be in the needed set so their module CSS definitions
2034        // are emitted in <head> during SSR.
2035        let mut fragments = HashMap::new();
2036
2037        // Entry → app-shell component
2038        fragments.insert(
2039            "index.html".to_string(),
2040            FragmentList {
2041                fragments: vec![WebUIFragment::component("app-shell")],
2042            },
2043        );
2044
2045        // app-shell has: navbar (component), route (outlet), cart-panel (component)
2046        fragments.insert(
2047            "app-shell".to_string(),
2048            FragmentList {
2049                fragments: vec![
2050                    WebUIFragment::component("my-navbar"),
2051                    WebUIFragment::route_from(WebUiFragmentRoute {
2052                        path: "/about".into(),
2053                        fragment_id: "page-about".into(),
2054                        exact: true,
2055                        keep_alive: false,
2056                        ..Default::default()
2057                    }),
2058                    WebUIFragment::component("cart-panel"),
2059                ],
2060            },
2061        );
2062
2063        // Leaf fragments
2064        fragments.insert(
2065            "my-navbar".to_string(),
2066            FragmentList {
2067                fragments: vec![WebUIFragment::raw("<nav/>")],
2068            },
2069        );
2070        fragments.insert(
2071            "page-about".to_string(),
2072            FragmentList {
2073                fragments: vec![WebUIFragment::raw("<h1>About</h1>")],
2074            },
2075        );
2076        fragments.insert(
2077            "cart-panel".to_string(),
2078            FragmentList {
2079                fragments: vec![WebUIFragment::raw("<aside>Cart</aside>")],
2080            },
2081        );
2082
2083        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2084        for name in ["app-shell", "my-navbar", "page-about", "cart-panel"] {
2085            let comp = protocol.components.entry(name.to_string()).or_default();
2086            comp.template = format!("(function(){{/* {name} */}})();");
2087            comp.css = format!(".{name}{{display:block}}");
2088        }
2089
2090        let comp_index = build_component_index(&protocol);
2091        let (needed, _inv) =
2092            get_needed_components_for_request(&protocol, "index.html", "/about", "", &comp_index)
2093                .unwrap();
2094
2095        assert!(
2096            needed.contains(&"app-shell".to_string()),
2097            "app-shell should be needed: {needed:?}"
2098        );
2099        assert!(
2100            needed.contains(&"my-navbar".to_string()),
2101            "my-navbar (non-route sibling) should be needed: {needed:?}"
2102        );
2103        assert!(
2104            needed.contains(&"page-about".to_string()),
2105            "page-about (active route) should be needed: {needed:?}"
2106        );
2107        assert!(
2108            needed.contains(&"cart-panel".to_string()),
2109            "cart-panel (non-route sibling) should be needed: {needed:?}"
2110        );
2111    }
2112
2113    // ── RouteChainEntry.to_json + allowed_query tests ────────────────
2114
2115    #[test]
2116    fn test_chain_entry_to_json_includes_allowed_query() {
2117        let entry = RouteChainEntry {
2118            component: "compose-page".into(),
2119            path: "compose".into(),
2120            params: HashMap::new(),
2121            exact: true,
2122            allowed_query: "action,to,subject".into(),
2123            keep_alive: false,
2124            cache_tags: Vec::new(),
2125            invalidates: Vec::new(),
2126            pending_component: String::new(),
2127            error_component: String::new(),
2128        };
2129        let json = entry.to_json();
2130        assert_eq!(json["allowedQuery"], "action,to,subject");
2131    }
2132
2133    #[test]
2134    fn test_chain_entry_to_json_omits_empty_allowed_query() {
2135        let entry = RouteChainEntry {
2136            component: "home-page".into(),
2137            path: "/".into(),
2138            params: HashMap::new(),
2139            exact: true,
2140            allowed_query: String::new(),
2141            keep_alive: false,
2142            cache_tags: Vec::new(),
2143            invalidates: Vec::new(),
2144            pending_component: String::new(),
2145            error_component: String::new(),
2146        };
2147        let json = entry.to_json();
2148        assert!(
2149            json.get("allowedQuery").is_none(),
2150            "empty allowed_query should not appear in JSON"
2151        );
2152    }
2153
2154    #[test]
2155    fn test_collect_route_chain_carries_allowed_query() {
2156        let mut fragments = HashMap::new();
2157        fragments.insert(
2158            "index.html".to_string(),
2159            FragmentList {
2160                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2161                    path: "/".into(),
2162                    fragment_id: "app-shell".into(),
2163                    exact: false,
2164                    children: vec![WebUiFragmentRoute {
2165                        path: "compose".into(),
2166                        fragment_id: "compose-page".into(),
2167                        exact: true,
2168                        allowed_query: "action,to".into(),
2169                        keep_alive: false,
2170                        ..Default::default()
2171                    }],
2172                    keep_alive: false,
2173                    ..Default::default()
2174                })],
2175            },
2176        );
2177        fragments.insert(
2178            "app-shell".to_string(),
2179            FragmentList {
2180                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2181            },
2182        );
2183        fragments.insert(
2184            "compose-page".to_string(),
2185            FragmentList {
2186                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
2187            },
2188        );
2189        let protocol = WebUIProtocol::new(fragments);
2190
2191        let chain = collect_route_chain(
2192            &protocol,
2193            "index.html",
2194            "/compose",
2195            &mut CompiledRouteCache::new(),
2196        );
2197        assert_eq!(chain.len(), 2);
2198        assert_eq!(chain[0].component, "app-shell");
2199        assert!(chain[0].allowed_query.is_empty());
2200        assert_eq!(chain[1].component, "compose-page");
2201        assert_eq!(chain[1].allowed_query, "action,to");
2202    }
2203
2204    #[test]
2205    fn test_render_component_templates_returns_template_and_css() {
2206        let mut fragments = HashMap::new();
2207        fragments.insert(
2208            "settings-dialog".to_string(),
2209            FragmentList {
2210                fragments: vec![WebUIFragment::raw("<div class='dialog'>Settings</div>")],
2211            },
2212        );
2213        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2214        let comp = protocol
2215            .components
2216            .entry("settings-dialog".to_string())
2217            .or_default();
2218        comp.template = "(function(){window.__webui.templates['settings-dialog']={h:'<div>Settings</div>'};})();".to_string();
2219        comp.css = ".dialog{position:fixed}".to_string();
2220
2221        let index = ProtocolIndex::new(&protocol);
2222        let result =
2223            render_component_templates(&protocol, &["settings-dialog"], "", &index).unwrap();
2224        let templates = result["templates"].as_array().expect("templates array");
2225        let styles = result["templateStyles"].as_array().expect("styles array");
2226
2227        assert_eq!(templates.len(), 1);
2228        assert!(templates[0].as_str().unwrap().contains("settings-dialog"));
2229        assert_eq!(styles.len(), 1);
2230        assert!(styles[0]
2231            .as_str()
2232            .unwrap()
2233            .contains(".dialog{position:fixed}"));
2234    }
2235
2236    #[test]
2237    fn test_render_component_templates_respects_inventory() {
2238        let mut fragments = HashMap::new();
2239        fragments.insert(
2240            "my-dialog".to_string(),
2241            FragmentList {
2242                fragments: vec![WebUIFragment::raw("<div>Dialog</div>")],
2243            },
2244        );
2245        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2246        let comp = protocol
2247            .components
2248            .entry("my-dialog".to_string())
2249            .or_default();
2250        comp.template = "(function(){})();".to_string();
2251        comp.css = ".d{color:red}".to_string();
2252
2253        let index = ProtocolIndex::new(&protocol);
2254        // First call: no inventory → should return the component
2255        let result1 = render_component_templates(&protocol, &["my-dialog"], "", &index).unwrap();
2256        let inv = result1["inventory"].as_str().expect("inventory string");
2257        assert_eq!(result1["templates"].as_array().unwrap().len(), 1);
2258
2259        // Second call with inventory → component already loaded, should skip
2260        let result2 = render_component_templates(&protocol, &["my-dialog"], inv, &index).unwrap();
2261        assert_eq!(result2["templates"].as_array().unwrap().len(), 0);
2262        assert_eq!(result2["templateStyles"].as_array().unwrap().len(), 0);
2263    }
2264
2265    #[test]
2266    fn test_render_component_templates_unknown_component_returns_empty() {
2267        let fragments = HashMap::new();
2268        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2269
2270        let index = ProtocolIndex::new(&protocol);
2271        let result =
2272            render_component_templates(&protocol, &["nonexistent-widget"], "", &index).unwrap();
2273        assert_eq!(result["templates"].as_array().unwrap().len(), 0);
2274        assert_eq!(result["templateStyles"].as_array().unwrap().len(), 0);
2275    }
2276
2277    // ── resolve_tag_templates tests ──────────────────────────────────
2278
2279    #[test]
2280    fn test_resolve_tag_templates_no_placeholders() {
2281        let tags = vec!["inbox".to_string(), "counts".to_string()];
2282        let params = HashMap::new();
2283        let resolved = resolve_tag_templates(&tags, &params);
2284        assert_eq!(resolved, vec!["inbox", "counts"]);
2285    }
2286
2287    #[test]
2288    fn test_resolve_tag_templates_with_params() {
2289        let tags = vec!["thread:{threadId}".to_string(), "inbox".to_string()];
2290        let mut params = HashMap::new();
2291        params.insert("threadId".to_string(), "42".to_string());
2292        let resolved = resolve_tag_templates(&tags, &params);
2293        assert_eq!(resolved, vec!["thread:42", "inbox"]);
2294    }
2295
2296    #[test]
2297    fn test_resolve_tag_templates_multiple_params() {
2298        let tags = vec!["folder:{folderId}:user:{userId}".to_string()];
2299        let mut params = HashMap::new();
2300        params.insert("folderId".to_string(), "drafts".to_string());
2301        params.insert("userId".to_string(), "abc".to_string());
2302        let resolved = resolve_tag_templates(&tags, &params);
2303        assert_eq!(resolved, vec!["folder:drafts:user:abc"]);
2304    }
2305
2306    #[test]
2307    fn test_resolve_tag_templates_missing_param_left_unresolved() {
2308        let tags = vec!["thread:{threadId}".to_string()];
2309        let params = HashMap::new();
2310        let resolved = resolve_tag_templates(&tags, &params);
2311        assert_eq!(resolved, vec!["thread:{threadId}"]);
2312    }
2313
2314    // ── Chain entry to_json with new fields ───────────────────────
2315
2316    #[test]
2317    fn test_chain_entry_to_json_includes_new_fields() {
2318        let entry = RouteChainEntry {
2319            component: "mail-thread".into(),
2320            path: "email/:threadId".into(),
2321            params: {
2322                let mut p = HashMap::new();
2323                p.insert("threadId".to_string(), "42".to_string());
2324                p
2325            },
2326            exact: true,
2327            allowed_query: String::new(),
2328            keep_alive: false,
2329            cache_tags: vec!["thread:42".to_string()],
2330            invalidates: vec!["inbox".to_string(), "counts".to_string()],
2331            pending_component: "mail-skeleton".into(),
2332            error_component: "error-page".into(),
2333        };
2334        let json = entry.to_json();
2335        assert_eq!(json["pendingComponent"], "mail-skeleton");
2336        assert_eq!(json["errorComponent"], "error-page");
2337        let inv = json["invalidates"].as_array().unwrap();
2338        assert_eq!(inv.len(), 2);
2339        assert_eq!(inv[0], "inbox");
2340        assert_eq!(inv[1], "counts");
2341    }
2342
2343    #[test]
2344    fn test_chain_entry_to_json_omits_empty_new_fields() {
2345        let entry = RouteChainEntry {
2346            component: "home-page".into(),
2347            path: "/".into(),
2348            params: HashMap::new(),
2349            exact: true,
2350            allowed_query: String::new(),
2351            keep_alive: false,
2352            cache_tags: Vec::new(),
2353            invalidates: Vec::new(),
2354            pending_component: String::new(),
2355            error_component: String::new(),
2356        };
2357        let json = entry.to_json();
2358        assert!(json.get("pendingComponent").is_none());
2359        assert!(json.get("errorComponent").is_none());
2360        assert!(json.get("invalidates").is_none());
2361    }
2362
2363    // ── render_partial with cache tags ────────────────────────────
2364
2365    #[test]
2366    fn test_render_partial_includes_resolved_cache_tags() {
2367        let mut fragments = HashMap::new();
2368        fragments.insert(
2369            "index.html".to_string(),
2370            FragmentList {
2371                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2372                    path: "/".into(),
2373                    fragment_id: "app-shell".into(),
2374                    exact: false,
2375                    children: vec![WebUiFragmentRoute {
2376                        path: "email/:threadId".into(),
2377                        fragment_id: "mail-thread".into(),
2378                        exact: true,
2379                        cache_tags: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
2380                        ..Default::default()
2381                    }],
2382                    cache_tags: vec!["folders".to_string()],
2383                    ..Default::default()
2384                })],
2385            },
2386        );
2387        fragments.insert(
2388            "app-shell".to_string(),
2389            FragmentList {
2390                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2391            },
2392        );
2393        fragments.insert(
2394            "mail-thread".to_string(),
2395            FragmentList {
2396                fragments: vec![WebUIFragment::raw("<p>Thread</p>")],
2397            },
2398        );
2399        let protocol = WebUIProtocol::new(fragments);
2400
2401        let mut index = ProtocolIndex::new(&protocol);
2402        let partial = render_partial(&protocol, "index.html", "/email/42", "", &mut index).unwrap();
2403        let tags = partial["cacheTags"].as_array().unwrap();
2404        let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
2405        assert!(
2406            tag_strings.contains(&"folders"),
2407            "should contain parent tags"
2408        );
2409        assert!(
2410            tag_strings.contains(&"thread:42"),
2411            "should resolve threadId param to 42"
2412        );
2413        assert!(tag_strings.contains(&"inbox"), "should contain static tags");
2414    }
2415
2416    // ── render_action_response tests ─────────────────────────────
2417
2418    #[test]
2419    fn test_render_action_response_returns_resolved_invalidation_tags() {
2420        let mut fragments = HashMap::new();
2421        fragments.insert(
2422            "index.html".to_string(),
2423            FragmentList {
2424                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2425                    path: "/".into(),
2426                    fragment_id: "app-shell".into(),
2427                    exact: false,
2428                    children: vec![WebUiFragmentRoute {
2429                        path: "compose".into(),
2430                        fragment_id: "compose-page".into(),
2431                        exact: true,
2432                        invalidates: vec![
2433                            "inbox".to_string(),
2434                            "sent".to_string(),
2435                            "counts".to_string(),
2436                        ],
2437                        ..Default::default()
2438                    }],
2439                    ..Default::default()
2440                })],
2441            },
2442        );
2443        fragments.insert(
2444            "app-shell".to_string(),
2445            FragmentList {
2446                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2447            },
2448        );
2449        fragments.insert(
2450            "compose-page".to_string(),
2451            FragmentList {
2452                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
2453            },
2454        );
2455        let protocol = WebUIProtocol::new(fragments);
2456
2457        let mut index = ProtocolIndex::new(&protocol);
2458        let result = render_action_response(
2459            &protocol,
2460            serde_json::json!({"ok": true}),
2461            "index.html",
2462            "/compose",
2463            &mut index,
2464        );
2465
2466        let tags = result["invalidateTags"].as_array().unwrap();
2467        let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
2468        assert_eq!(tag_strings, vec!["inbox", "sent", "counts"]);
2469        assert_eq!(result["state"]["ok"], true);
2470    }
2471
2472    #[test]
2473    fn test_render_action_response_resolves_param_placeholders() {
2474        let mut fragments = HashMap::new();
2475        fragments.insert(
2476            "index.html".to_string(),
2477            FragmentList {
2478                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2479                    path: "/".into(),
2480                    fragment_id: "app-shell".into(),
2481                    exact: false,
2482                    children: vec![WebUiFragmentRoute {
2483                        path: "email/:threadId/reply".into(),
2484                        fragment_id: "reply-page".into(),
2485                        exact: true,
2486                        invalidates: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
2487                        ..Default::default()
2488                    }],
2489                    ..Default::default()
2490                })],
2491            },
2492        );
2493        fragments.insert(
2494            "app-shell".to_string(),
2495            FragmentList {
2496                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2497            },
2498        );
2499        fragments.insert(
2500            "reply-page".to_string(),
2501            FragmentList {
2502                fragments: vec![WebUIFragment::raw("<p>Reply</p>")],
2503            },
2504        );
2505        let protocol = WebUIProtocol::new(fragments);
2506
2507        let mut index = ProtocolIndex::new(&protocol);
2508        let result = render_action_response(
2509            &protocol,
2510            serde_json::json!({}),
2511            "index.html",
2512            "/email/42/reply",
2513            &mut index,
2514        );
2515
2516        let tags = result["invalidateTags"].as_array().unwrap();
2517        let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
2518        assert!(tag_strings.contains(&"thread:42"));
2519        assert!(tag_strings.contains(&"inbox"));
2520    }
2521}