Skip to main content

webui_handler/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! WebUI Handler implementation for Rust.
5//!
6//! This crate provides functionality to process and render WebUI protocols
7//! into final HTML output based on provided data.
8
9pub mod css_module;
10pub(crate) mod html_encode;
11pub mod plugin;
12pub mod route_handler;
13pub mod route_matcher;
14pub(crate) mod route_renderer;
15
16pub use route_handler::Protocol;
17
18/// Minimal HTML escaper for the 6 XSS-critical characters
19/// (`& < > " ' /`). Returns `Cow::Borrowed` when no escaping is
20/// needed (zero allocation on the happy path), `Cow::Owned` when
21/// any character had to be replaced.
22///
23/// Re-exported here so external callers of `RenderOptions::with_head_inject`
24/// / `with_body_inject` can pre-escape untrusted content with the
25/// same escaper the handler uses internally for SSR text content,
26/// without having to pull in a separate HTML-escape crate.
27pub use html_encode::encode_safe;
28
29use plugin::BootstrapExtensionContext;
30use plugin::HandlerPlugin;
31use plugin::WebUiTemplatePayload;
32use route_matcher::CompiledRouteIndex;
33use serde::ser::SerializeMap;
34use serde::Serialize;
35use serde_json::Value;
36use std::borrow::Cow;
37use std::collections::{HashMap, HashSet};
38use thiserror::Error;
39use webui_expressions::{evaluate_with_resolver, ExpressionError};
40use webui_protocol::{
41    web_ui_fragment::Fragment, InitialStateStrategy, StateProjectionMode, WebUIFragment,
42    WebUIProtocol,
43};
44use webui_state::find_value_by_dotted_path_ref;
45
46/// Error types for the WebUI handler.
47#[derive(Debug, Error)]
48pub enum HandlerError {
49    #[error("Rendering error: {0}")]
50    Rendering(String),
51
52    #[error("Rendering invariant error: {0}")]
53    Invariant(String),
54
55    #[error("Missing fragment: {0}")]
56    MissingFragment(String),
57
58    #[error("Missing data field: {0}")]
59    MissingData(String),
60
61    #[error("Type error: {0}")]
62    TypeError(String),
63
64    #[error("Protocol error: {0}")]
65    Protocol(#[from] webui_protocol::ProtocolError),
66
67    #[error("Evaluation error: {0}")]
68    Evaluation(String),
69
70    #[error("I/O error: {0}")]
71    Io(#[from] std::io::Error),
72
73    #[error("Writer error: {0}")]
74    Writer(String),
75
76    #[error("Plugin data error: {0}")]
77    PluginData(String),
78
79    /// The HTTP client disconnected before the render completed.
80    ///
81    /// Streaming `ResponseWriter` implementations return this from
82    /// `write()` once their channel/socket is closed, so the handler
83    /// can abort the render rather than do CPU work that has nowhere
84    /// to go. Allocation-free (the variant carries no payload).
85    #[error("client disconnected")]
86    ClientDisconnected,
87
88    /// The streaming writer's flush exceeded its configured deadline.
89    ///
90    /// Indicates a slow/unresponsive consumer (slow-loris client,
91    /// stuck proxy, etc.). The render thread is freed; downstream
92    /// telemetry should distinguish this from `ClientDisconnected`
93    /// so ops can alert on slow-client attacks.
94    #[error("streaming flush timed out")]
95    StreamTimeout,
96}
97
98pub type Result<T> = std::result::Result<T, HandlerError>;
99
100/// Interface for writing rendered output
101pub trait ResponseWriter {
102    /// Write content to the output
103    fn write(&mut self, content: &str) -> Result<()>;
104
105    /// Finalize the output
106    fn end(&mut self) -> Result<()>;
107}
108
109/// Options controlling how the handler renders a protocol.
110///
111/// The handler performs server-side route matching: matched routes are rendered
112/// visible with content; non-matched routes are rendered hidden and empty.
113pub struct RenderOptions<'a> {
114    /// The fragment ID to start rendering from (e.g., `"index.html"`).
115    pub entry_id: &'a str,
116    /// The URL path to match routes against (e.g., `"/contacts/42"`).
117    pub request_path: &'a str,
118    /// Optional CSP nonce for inline `<script>` tags.
119    /// When set, all inline scripts include `nonce="VALUE"` and a
120    /// `<meta name="webui-nonce">` tag is emitted for the client router.
121    pub nonce: Option<&'a str>,
122    /// Optional HTML to emit immediately before the document's
123    /// `</head>` close. Used for per-request `<link rel="preload">`
124    /// hints, CSP `<meta>` tags beyond the built-in nonce, etc.
125    /// Inserted at the structural `head_end` boundary identified by
126    /// the parser — never matched against a byte pattern, so cannot
127    /// be tricked by `</head>` literals appearing in HTML comments,
128    /// `srcdoc` attributes, or inline scripts.
129    pub head_inject: Option<&'a str>,
130    /// Optional HTML to emit immediately before the document's
131    /// `</body>` close. Used for dev livereload `<script>`, analytics
132    /// snippets, OpenTelemetry trace IDs, etc.
133    /// Same structural-boundary guarantee as [`head_inject`](Self::head_inject).
134    pub body_inject: Option<&'a str>,
135}
136
137impl<'a> RenderOptions<'a> {
138    /// Create render options for the given entry fragment and request path.
139    #[must_use]
140    pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
141        Self {
142            entry_id,
143            request_path,
144            nonce: None,
145            head_inject: None,
146            body_inject: None,
147        }
148    }
149
150    /// Set the CSP nonce for inline scripts. Pass an empty string to
151    /// disable (`None` semantics) — empty `<meta name="webui-nonce"
152    /// content="">` would be browser-ignored noise.
153    #[must_use]
154    pub fn with_nonce(mut self, nonce: &'a str) -> Self {
155        self.nonce = if nonce.is_empty() { None } else { Some(nonce) };
156        self
157    }
158
159    /// Set HTML to emit immediately before `</head>`.
160    /// Pass an empty string to disable (`None` semantics).
161    ///
162    /// # Safety (XSS warning)
163    ///
164    /// The provided HTML is written verbatim — **no HTML escaping is
165    /// performed**. Callers MUST ensure the content is fully trusted
166    /// (typically a `&'static str` or build-time-derived bytes such as
167    /// dev livereload script, image preload `<link>` tags, or A/B test
168    /// markers). Passing user-controlled or attacker-influenced content
169    /// here is a direct cross-site scripting vulnerability. If your
170    /// caller path may include untrusted data, escape with the host's
171    /// HTML escaper (e.g. [`webui_handler::encode_safe`](crate::encode_safe))
172    /// **before** calling this builder.
173    #[must_use]
174    pub fn with_head_inject(mut self, html: &'a str) -> Self {
175        self.head_inject = if html.is_empty() { None } else { Some(html) };
176        self
177    }
178
179    /// Set HTML to emit immediately before `</body>`.
180    /// Pass an empty string to disable (`None` semantics).
181    ///
182    /// # Safety (XSS warning)
183    ///
184    /// Same contract as [`with_head_inject`](Self::with_head_inject):
185    /// the HTML is written verbatim with **no escaping**, so callers
186    /// MUST ensure the content is fully trusted. Untrusted content is
187    /// a direct XSS vector.
188    #[must_use]
189    pub fn with_body_inject(mut self, html: &'a str) -> Self {
190        self.body_inject = if html.is_empty() { None } else { Some(html) };
191        self
192    }
193}
194
195/// The main WebUI handler that processes protocols and renders them.
196///
197/// The handler is stateless: plugin instances are created per-render from
198/// the stored factory function, allowing concurrent renders with `&self`.
199pub struct WebUIHandler {
200    plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
201}
202
203/// Context object for processing WebUI fragments
204struct WebUIProcessContext<'a> {
205    protocol: &'a WebUIProtocol,
206    state: &'a Value,
207    writer: &'a mut dyn ResponseWriter,
208    local_vars: HashMap<String, Value>,
209    /// Accumulates component attribute values between attrStart and the component fragment.
210    component_attrs: HashMap<String, Value>,
211    /// URL path for server-side route matching. Borrowed from
212    /// `RenderOptions<'a>::request_path` — zero-copy.
213    request_path: &'a str,
214    /// Base path for resolving relative route paths (`./`).
215    /// Updated as the handler descends into nested matched routes.
216    /// `Cow` keeps the initial `"/"` literal zero-copy; nested-route
217    /// descent owns the recomputed path.
218    route_base: Cow<'a, str>,
219    /// Component names visited during rendering (for selective f-template emission
220    /// and CSS module dedup — only the first render of each component emits
221    /// its `<script type="importmap">` data-URI tag).
222    rendered_components: HashSet<String>,
223    /// Per-render plugin instance created from the handler's factory.
224    plugin: Option<Box<dyn HandlerPlugin>>,
225    /// Current position in the route tree for outlet-based rendering.
226    /// Contains the children of the currently matched route fragment.
227    route_children: Vec<webui_protocol::WebUiFragmentRoute>,
228    /// Entry fragment ID — used to compute the initial inventory at head_end.
229    /// Borrowed from `RenderOptions<'a>::entry_id` — zero-copy.
230    entry_id: &'a str,
231    /// CSP nonce for inline `<script>` tags (None = no nonce attribute).
232    /// Borrowed from `RenderOptions<'a>::nonce` — zero-copy.
233    nonce: Option<&'a str>,
234    /// Component-name → bit-position map built once when the runtime
235    /// [`Protocol`] is created and shared by every render.
236    component_index: &'a HashMap<String, u32>,
237    /// HTML emitted at the structural `head_end` boundary (before
238    /// `</head>`), after the built-in nonce/CSS-preload emissions.
239    /// Zero-copy borrow of the caller's `RenderOptions<'a>::head_inject`
240    /// (no per-render clone — saves an allocation when the host passes
241    /// a `&'static str` such as a dev livereload script).
242    head_inject: Option<&'a str>,
243    /// HTML emitted at the structural `body_end` boundary (before
244    /// `</body>`), after the built-in template metadata emissions.
245    /// Same zero-copy borrow as [`head_inject`](Self::head_inject).
246    body_inject: Option<&'a str>,
247    /// Tracks whether the `head_end` hook has already fired in this
248    /// render. Defends against malformed protocols that emit the
249    /// signal more than once (e.g., a template with multiple `<head>`
250    /// tags) — without this, host-supplied `head_inject` HTML, CSS
251    /// preload `<link>` tags, and the CSP `<meta>` nonce would be
252    /// duplicated, which can be a CSP-bypass / cache-bloat vector.
253    head_end_emitted: bool,
254    /// Tracks whether the `body_end` hook has already fired in this
255    /// render. Defends against malformed protocols emitting the
256    /// signal twice — without this, hydration `<script>` blocks and
257    /// host-supplied `body_inject` would be duplicated.
258    body_end_emitted: bool,
259    /// Immutable authored route patterns compiled when [`Protocol`] is loaded.
260    route_index: &'a CompiledRouteIndex,
261    /// Counter for `data-ri` attributes on matched route elements.
262    /// Incremented each time a matched route is rendered, allowing O(1) element
263    /// binding on the client side instead of DOM-walking.
264    route_chain_index: usize,
265}
266
267struct WebUiBootstrap<'a> {
268    state: &'a Value,
269    state_selection: StateSelection<'a>,
270    chain: &'a [Value],
271    inventory: &'a str,
272    nonce: Option<&'a str>,
273    css_hrefs: &'a [&'a str],
274    style_specs: &'a [&'a str],
275    templates: &'a [WebUiTemplatePayload<'a>],
276}
277
278/// Get the component attribute name, stripping `:` prefix and converting to camelCase.
279///
280/// Uses `webui_protocol::attrs::attribute_to_camel` which handles irregular
281/// attributes (multi-word ARIA and global HTML attributes like `readonly`,
282/// `tabindex`) via the shared lookup table.
283fn component_attr_name(name: &str) -> String {
284    let stripped = name.strip_prefix(':').unwrap_or(name);
285    webui_protocol::attrs::attribute_to_camel(stripped)
286}
287
288/// Write a usize as decimal digits directly to the writer, avoiding `format!` allocation.
289fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
290    if n == 0 {
291        return writer.write("0");
292    }
293    // Max digits for a 64-bit usize is 20.
294    let mut buf = [0u8; 20];
295    let mut pos = buf.len();
296    while n > 0 {
297        pos -= 1;
298        // n % 10 is always in 0..=9, fits in u8 without truncation.
299        #[allow(clippy::cast_possible_truncation)]
300        let digit = (n % 10) as u8;
301        buf[pos] = b'0' + digit;
302        n /= 10;
303    }
304    // Digits are always valid ASCII/UTF-8.
305    match std::str::from_utf8(&buf[pos..]) {
306        Ok(s) => writer.write(s),
307        Err(_) => writer.write("0"),
308    }
309}
310
311pub(crate) fn write_script_safe_json<T>(writer: &mut dyn ResponseWriter, value: &T) -> Result<()>
312where
313    T: Serialize + ?Sized,
314{
315    let mut json = Vec::with_capacity(256);
316    serde_json::to_writer(&mut json, value)
317        .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
318    let json = std::str::from_utf8(&json)
319        .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
320    write_script_safe_json_str(writer, json)
321}
322
323fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
324    let mut start = 0;
325    while start < json.len() {
326        let rest = &json[start..];
327        let Some(offset) = rest.find("</") else {
328            writer.write(rest)?;
329            return Ok(());
330        };
331
332        if offset > 0 {
333            writer.write(&rest[..offset])?;
334        }
335        writer.write("<\\/")?;
336        start += offset + 2;
337    }
338    Ok(())
339}
340
341fn write_json_field_name(
342    writer: &mut dyn ResponseWriter,
343    wrote_field: &mut bool,
344    name: &str,
345) -> Result<()> {
346    if *wrote_field {
347        writer.write(",")?;
348    }
349    *wrote_field = true;
350    writer.write("\"")?;
351    writer.write(name)?;
352    writer.write("\":")
353}
354
355fn write_json_field<T>(
356    writer: &mut dyn ResponseWriter,
357    wrote_field: &mut bool,
358    name: &str,
359    value: &T,
360) -> Result<()>
361where
362    T: Serialize + ?Sized,
363{
364    write_json_field_name(writer, wrote_field, name)?;
365    write_script_safe_json(writer, value)
366}
367
368/// Serialize wrapper that projects an SSR state object down to only the
369/// keys present in the build-time hydration allowlist.
370///
371/// This is the runtime half of the projected-hydration design: instead of
372/// serializing the entire application state (potentially megabytes) on every
373/// full-HTML render, only the fields a component actually hydrates are
374/// emitted. The request allowlist conservatively includes every reachable
375/// component's hydration keys so no field a component needs is dropped.
376///
377/// Projection is a payload boundary, not a secrecy boundary. Any key selected
378/// by compiled client metadata is browser-facing, so hosts must never place
379/// secrets in browser render state.
380///
381/// `keys` MUST be sorted and deduplicated. Projection iterates whichever side
382/// is smaller: hydration keys with direct map lookup for wide states, or state
383/// entries with binary-search membership for compact states. Non-object states
384/// carry nothing hydratable and serialize as an empty object.
385struct ProjectedState<'a> {
386    value: &'a Value,
387    keys: &'a [&'a str],
388}
389
390impl Serialize for ProjectedState<'_> {
391    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
392    where
393        S: serde::Serializer,
394    {
395        let Value::Object(map) = self.value else {
396            return serializer.serialize_map(Some(0))?.end();
397        };
398
399        let mut out = serializer.serialize_map(None)?;
400        if self.keys.len() < map.len() {
401            let mut previous = None;
402            for key in self.keys {
403                if previous == Some(*key) {
404                    continue;
405                }
406                previous = Some(*key);
407                if let Some(value) = map.get(*key) {
408                    out.serialize_entry(key, value)?;
409                }
410            }
411        } else {
412            for (key, value) in map {
413                if self
414                    .keys
415                    .binary_search_by(|candidate| candidate.cmp(&key.as_str()))
416                    .is_ok()
417                {
418                    out.serialize_entry(key, value)?;
419                }
420            }
421        }
422        out.end()
423    }
424}
425
426/// Write the SSR `state` into the bootstrap block according to the protocol's
427/// build-time selection and escape it for safe embedding in a `<script>`.
428///
429/// [`ProjectedState`] serializes only the allowlisted keys, so for the typical
430/// payload — a large state with a small hydratable surface — serde ever only
431/// touches the projected subset. Serialization reuses the proven
432/// [`write_script_safe_json`] path (serde's fast `Vec<u8>` target plus a single
433/// SIMD-accelerated `</` escape pass), which matches the pre-projection cost
434/// when every key is hydratable and collapses to a few bytes when it is not.
435/// Buffering the projected bytes and escaping once is measurably faster than
436/// streaming through a per-token `io::Write` adapter, and the projected buffer
437/// is tiny in the common case.
438fn write_selected_state(
439    writer: &mut dyn ResponseWriter,
440    state: &Value,
441    selection: &StateSelection<'_>,
442) -> Result<()> {
443    let StateSelection::Keys(keys) = selection else {
444        return write_script_safe_json(writer, state);
445    };
446    if keys.is_empty() {
447        return writer.write("{}");
448    }
449
450    // Projection membership may use binary search, so a mis-sorted key set
451    // would silently drop hydration keys. The key allowlist is produced sorted +
452    // deduped at build time; this guard makes hand-built protocols that violate
453    // the invariant fail loudly in tests at zero release cost.
454    debug_assert!(
455        keys.windows(2).all(|pair| pair[0] <= pair[1]),
456        "hydration keys must be sorted for binary-search projection"
457    );
458    if let Value::Object(map) = state {
459        let selects_entire_map =
460            keys.len() == map.len() && keys.iter().copied().eq(map.keys().map(String::as_str));
461        if selects_entire_map {
462            return write_script_safe_json(writer, state);
463        }
464    }
465    write_script_safe_json(writer, &ProjectedState { value: state, keys })
466}
467
468// Covers the common route surface without trusting protocol-derived counts for
469// an eager allocation; larger key sets grow only as actual keys are visited.
470const INITIAL_KEY_CAPACITY: usize = 16;
471
472/// Request-scoped state selection derived from reachable component metadata.
473pub(crate) enum StateSelection<'a> {
474    /// Preserve the complete state value.
475    Full,
476    /// Project an object to a sorted, deduplicated key allowlist.
477    Keys(Vec<&'a str>),
478}
479
480enum ComponentStateSurface {
481    Hydration,
482    Navigation,
483}
484
485/// Select initial state for the components reachable on this request path.
486///
487/// Non-WebUI protocols preserve full state without walking component surfaces.
488/// WebUI protocols project exact surfaces, while any unknown surface restores
489/// the full state for correctness.
490pub(crate) fn collect_hydration_state<'a, 'b>(
491    protocol: &'a WebUIProtocol,
492    components: impl IntoIterator<Item = &'b str>,
493) -> StateSelection<'a> {
494    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
495        return StateSelection::Full;
496    }
497    collect_component_state(protocol, components, ComponentStateSurface::Hydration)
498}
499
500/// Select state for client-created components reachable during navigation.
501pub(crate) fn collect_navigation_state<'a, 'b>(
502    protocol: &'a WebUIProtocol,
503    components: impl IntoIterator<Item = &'b str>,
504) -> StateSelection<'a> {
505    collect_component_state(protocol, components, ComponentStateSurface::Navigation)
506}
507
508fn collect_component_state<'a, 'b>(
509    protocol: &'a WebUIProtocol,
510    components: impl IntoIterator<Item = &'b str>,
511    surface: ComponentStateSurface,
512) -> StateSelection<'a> {
513    let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
514    for name in components {
515        let Some(component) = protocol.components.get(name) else {
516            return StateSelection::Full;
517        };
518        let (mode, component_keys) = match surface {
519            ComponentStateSurface::Hydration => {
520                (component.hydration_mode, &component.hydration_keys)
521            }
522            ComponentStateSurface::Navigation => {
523                (component.navigation_mode, &component.navigation_keys)
524            }
525        };
526        if mode == StateProjectionMode::All as i32 {
527            return StateSelection::Full;
528        }
529        if mode == StateProjectionMode::Keys as i32
530            || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
531        {
532            keys.extend(component_keys.iter().map(String::as_str));
533        } else if mode != StateProjectionMode::None as i32 {
534            return StateSelection::Full;
535        }
536    }
537    keys.sort_unstable();
538    keys.dedup();
539    StateSelection::Keys(keys)
540}
541
542fn write_webui_bootstrap(
543    writer: &mut dyn ResponseWriter,
544    bootstrap: WebUiBootstrap<'_>,
545) -> Result<()> {
546    let mut wrote_field = false;
547
548    writer.write("{")?;
549    if !bootstrap.chain.is_empty() {
550        write_json_field(writer, &mut wrote_field, "chain", bootstrap.chain)?;
551    }
552    if !bootstrap.css_hrefs.is_empty() {
553        write_json_field(writer, &mut wrote_field, "css", bootstrap.css_hrefs)?;
554    }
555    write_json_field(writer, &mut wrote_field, "inventory", bootstrap.inventory)?;
556    if let Some(nonce) = bootstrap.nonce {
557        write_json_field(writer, &mut wrote_field, "nonce", nonce)?;
558    }
559    write_json_field_name(writer, &mut wrote_field, "state")?;
560    write_selected_state(writer, bootstrap.state, &bootstrap.state_selection)?;
561    if !bootstrap.style_specs.is_empty() {
562        write_json_field(writer, &mut wrote_field, "styles", bootstrap.style_specs)?;
563    }
564    if bootstrap
565        .templates
566        .iter()
567        .any(|template| !template.template_json.is_empty())
568    {
569        write_json_field_name(writer, &mut wrote_field, "templates")?;
570        write_webui_template_json_map(writer, bootstrap.templates)?;
571    }
572    writer.write("}")
573}
574
575fn write_webui_data_block(
576    writer: &mut dyn ResponseWriter,
577    bootstrap: WebUiBootstrap<'_>,
578) -> Result<()> {
579    writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
580    if let Some(nonce) = bootstrap.nonce {
581        writer.write(" nonce=\"")?;
582        writer.write(nonce)?;
583        writer.write("\"")?;
584    }
585    writer.write(">")?;
586    write_webui_bootstrap(writer, bootstrap)?;
587    writer.write("</script>\n")
588}
589
590fn write_webui_template_json_map(
591    writer: &mut dyn ResponseWriter,
592    templates: &[WebUiTemplatePayload<'_>],
593) -> Result<()> {
594    writer.write("{")?;
595    let mut wrote = false;
596    for template in templates {
597        if template.template_json.is_empty() {
598            continue;
599        }
600        if wrote {
601            writer.write(",")?;
602        }
603        wrote = true;
604        write_script_safe_json(writer, template.tag_name)?;
605        writer.write(":")?;
606        write_script_safe_json_str(writer, template.template_json)?;
607    }
608    writer.write("}")
609}
610
611fn resolve_value_from_sources<'ctx, 'state>(
612    path: &str,
613    local_vars: &'ctx HashMap<String, Value>,
614    state: &'state Value,
615) -> Option<Cow<'ctx, Value>>
616where
617    'state: 'ctx,
618{
619    if let Some(first_part) = path.split('.').next() {
620        if let Some(local_value) = local_vars.get(first_part) {
621            if first_part.len() == path.len() {
622                return Some(Cow::Borrowed(local_value));
623            }
624            let remaining = &path[first_part.len() + 1..];
625            if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
626                return Some(value);
627            }
628        }
629    }
630
631    find_value_by_dotted_path_ref(path, state)
632}
633
634impl WebUIHandler {
635    /// Create a new WebUI handler with no plugin.
636    pub fn new() -> Self {
637        Self {
638            plugin_factory: None,
639        }
640    }
641
642    /// Create a new WebUI handler with a plugin factory.
643    ///
644    /// Each render call creates a fresh plugin instance from the factory,
645    /// enabling concurrent renders with `&self`.
646    pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
647        Self {
648            plugin_factory: Some(factory),
649        }
650    }
651
652    #[cfg(test)]
653    fn handle(
654        &self,
655        document: &WebUIProtocol,
656        state: &Value,
657        options: &RenderOptions<'_>,
658        writer: &mut dyn ResponseWriter,
659    ) -> Result<()> {
660        let protocol = Protocol::new(document.clone());
661        self.render(&protocol, state, options, writer)
662    }
663
664    /// Process a fragment by its ID.
665    ///
666    /// The `context` parameter contains scope-local variables that are accessible during rendering,
667    /// such as loop iteration variables. This is separate from the global `state`.
668    fn process_fragment_id(
669        &self,
670        fragment_id: &str,
671        context: &mut WebUIProcessContext,
672    ) -> Result<()> {
673        if let Some(fragment_list) = context.protocol.fragments.get(fragment_id) {
674            self.process_fragment(&fragment_list.fragments, context)
675        } else {
676            Err(HandlerError::MissingFragment(fragment_id.to_string()))
677        }
678    }
679
680    /// Process a vector of fragments.
681    ///
682    /// The `context` maintains scope-specific variables that can be accessed by fragments
683    /// during rendering, while `state` contains the global application state.
684    fn process_fragment(
685        &self,
686        fragments: &[WebUIFragment],
687        context: &mut WebUIProcessContext,
688    ) -> Result<()> {
689        // Pre-scan: find the best matching route among sibling routes by specificity.
690        // This ensures `/contacts/add` (2 literals) beats `/contacts/:id` (1 literal).
691        // Resolves relative paths (`./`) using the current route_base.
692        let best_route = route_renderer::find_best_route_match(
693            fragments,
694            context.request_path,
695            &context.route_base,
696            context.route_index,
697        );
698
699        for item in fragments {
700            match item.fragment.as_ref() {
701                Some(Fragment::Raw(raw)) => {
702                    context.writer.write(&raw.value)?;
703                }
704                Some(Fragment::Component(component)) => {
705                    self.process_component(component, context)?;
706                }
707                Some(Fragment::ForLoop(for_loop)) => {
708                    self.process_for_loop(for_loop, context)?;
709                }
710                Some(Fragment::Signal(signal)) => {
711                    self.process_signal(signal, context)?;
712                }
713                Some(Fragment::IfCond(if_cond)) => {
714                    self.process_if(if_cond, context)?;
715                }
716                Some(Fragment::Attribute(attr)) => {
717                    self.process_attribute(attr, context)?;
718                }
719                Some(Fragment::Plugin(plugin_frag)) => {
720                    if let Some(p) = &mut context.plugin {
721                        p.on_element_data(&plugin_frag.data, context.writer)?;
722                    }
723                }
724                Some(Fragment::Route(route_frag)) => {
725                    self.process_route(route_frag, &best_route, context)?;
726                }
727                Some(Fragment::Outlet(_)) => {
728                    self.process_outlet(context)?;
729                }
730                None => {}
731            }
732        }
733        Ok(())
734    }
735
736    /// Process an `<outlet />` directive.
737    ///
738    /// Matches children from the currently active route's `children` field
739    /// against the request path, renders the matched child `<webui-route>`
740    /// elements directly at this position (no wrapper element).
741    fn process_outlet(&self, context: &mut WebUIProcessContext) -> Result<()> {
742        let mut children = std::mem::take(&mut context.route_children);
743        if children.is_empty() {
744            return Ok(());
745        }
746
747        // Find the best matching child route
748        let request_segments = route_matcher::split_request_path(context.request_path);
749        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
750        for (idx, child) in children.iter().enumerate() {
751            if let Some(m) = route_matcher::match_route_indexed_with_segments(
752                context.route_index,
753                &child.path,
754                &context.route_base,
755                &request_segments,
756                child.exact,
757            ) {
758                let is_better = best
759                    .as_ref()
760                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
761                if is_better {
762                    best = Some((idx, m));
763                }
764            }
765        }
766
767        // Extract grandchildren from the matched child to avoid cloning.
768        // We swap out the children vec so we can move it into context without
769        // cloning, then swap an empty vec back for the sibling rendering pass.
770        let grandchildren = if let Some((idx, _)) = &best {
771            std::mem::take(&mut children[*idx].children)
772        } else {
773            Vec::new()
774        };
775
776        if let Some((idx, ref rm)) = best {
777            let matched_child = &children[idx];
778            let comp = &matched_child.fragment_id;
779
780            if !comp.is_empty() {
781                let saved_route_base = context.route_base.clone();
782                let saved_route_children = std::mem::take(&mut context.route_children);
783
784                if rm.consumed_segments > 0 {
785                    context.route_base = Cow::Owned(route_matcher::compute_route_base(
786                        context.request_path,
787                        rm.consumed_segments,
788                    ));
789                }
790
791                context.route_children = grandchildren;
792
793                // Emit matched <webui-route>
794                context.writer.write("<webui-route")?;
795                if !matched_child.path.is_empty() {
796                    context.writer.write(" path=\"")?;
797                    context.writer.write(&matched_child.path)?;
798                    context.writer.write("\"")?;
799                }
800                context.writer.write(" component=\"")?;
801                context.writer.write(comp)?;
802                context.writer.write("\"")?;
803                if matched_child.exact {
804                    context.writer.write(" exact")?;
805                }
806                route_renderer::write_route_pending_attrs(context.writer, matched_child)?;
807                // Emit data-ri for O(1) client-side element binding
808                let ri = context.route_chain_index;
809                context.route_chain_index += 1;
810                context.writer.write(" data-ri=\"")?;
811                write_usize(context.writer, ri)?;
812                context.writer.write("\" active>")?;
813
814                context.writer.write("<")?;
815                context.writer.write(comp)?;
816                if let Some(p) = &context.plugin {
817                    p.write_route_component_state(context.state, context.writer)?;
818                }
819                context.writer.write(">")?;
820
821                self.process_component(
822                    &webui_protocol::WebUIFragmentComponent {
823                        fragment_id: comp.clone(),
824                    },
825                    context,
826                )?;
827
828                context.writer.write("</")?;
829                context.writer.write(comp)?;
830                context.writer.write(">")?;
831                context.writer.write("</webui-route>")?;
832
833                context.route_base = saved_route_base;
834                context.route_children = saved_route_children;
835            }
836        }
837
838        // Render non-matched siblings as hidden
839        for (idx, child) in children.iter().enumerate() {
840            let is_matched = best.as_ref().is_some_and(|(bi, _)| *bi == idx);
841            if !is_matched && !child.fragment_id.is_empty() {
842                context.writer.write("<webui-route")?;
843                if !child.path.is_empty() {
844                    context.writer.write(" path=\"")?;
845                    context.writer.write(&child.path)?;
846                    context.writer.write("\"")?;
847                }
848                context.writer.write(" component=\"")?;
849                context.writer.write(&child.fragment_id)?;
850                context.writer.write("\"")?;
851                if child.exact {
852                    context.writer.write(" exact")?;
853                }
854                route_renderer::write_route_pending_attrs(context.writer, child)?;
855                context
856                    .writer
857                    .write(" style=\"display:none\"></webui-route>")?;
858            }
859        }
860
861        Ok(())
862    }
863
864    /// Emit a `<script type="importmap">` tag that registers a component's
865    /// CSS module under its specifier via a `data:text/css,…` URI.
866    ///
867    /// Requires Multiple Import Maps (Chrome 133+); each call emits an
868    /// independent importmap that the browser merges at the document
869    /// level. The per-render CSP nonce is applied when set (importmap
870    /// scripts honor `script-src`).
871    ///
872    /// Example for `my-comp` with CSS `span{color:blue;}`:
873    /// `<script type="importmap" nonce="...">{"imports":{"my-comp":"data:text/css,span{color:blue;}"}}</script>`
874    fn emit_css_module_importmap(
875        &self,
876        specifier: &str,
877        css: &str,
878        context: &mut WebUIProcessContext,
879    ) -> Result<()> {
880        let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
881        context.writer.write(&tag)?;
882        Ok(())
883    }
884
885    /// Emit a component's CSS module importmap on its first render
886    /// (deduped by `rendered_components`) into the component's light DOM,
887    /// so the browser registers it under the component's specifier
888    /// before the shadow root template is parsed. See
889    /// [`Self::emit_css_module_importmap`] for the emitted shape.
890    ///
891    /// Only components rendered on the current route get inline
892    /// definitions; others receive theirs via `templateStyles` during
893    /// SPA partial navigation.
894    fn emit_css_module(
895        &self,
896        component: &webui_protocol::WebUIFragmentComponent,
897        context: &mut WebUIProcessContext,
898    ) -> Result<()> {
899        if !context.rendered_components.contains(&component.fragment_id) {
900            if let Some(css) = context
901                .protocol
902                .components
903                .get(&component.fragment_id)
904                .map(|c| c.css.as_str())
905                .filter(|s| !s.is_empty())
906            {
907                self.emit_css_module_importmap(&component.fragment_id, css, context)?;
908            }
909        }
910        Ok(())
911    }
912
913    /// Process a route fragment — renders `<webui-route>` with matched/hidden state.
914    fn process_route(
915        &self,
916        route_frag: &webui_protocol::WebUiFragmentRoute,
917        best_route: &Option<(String, route_matcher::RouteMatch)>,
918        context: &mut WebUIProcessContext,
919    ) -> Result<()> {
920        let is_matched = best_route
921            .as_ref()
922            .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
923
924        context.writer.write("<webui-route")?;
925        if !route_frag.path.is_empty() {
926            context.writer.write(" path=\"")?;
927            context.writer.write(&route_frag.path)?;
928            context.writer.write("\"")?;
929        }
930        if !route_frag.fragment_id.is_empty() {
931            context.writer.write(" component=\"")?;
932            context.writer.write(&route_frag.fragment_id)?;
933            context.writer.write("\"")?;
934        }
935        if route_frag.exact {
936            context.writer.write(" exact")?;
937        }
938        route_renderer::write_route_pending_attrs(context.writer, route_frag)?;
939
940        if is_matched {
941            // Emit data-ri for O(1) client-side element binding
942            let ri = context.route_chain_index;
943            context.route_chain_index += 1;
944            context.writer.write(" data-ri=\"")?;
945            write_usize(context.writer, ri)?;
946            context.writer.write("\" active>")?;
947
948            if !route_frag.fragment_id.is_empty() {
949                let saved_route_base = context.route_base.clone();
950                let saved_route_children = std::mem::take(&mut context.route_children);
951                if let Some((_, ref rm)) = best_route {
952                    context.route_base = Cow::Owned(route_matcher::compute_route_base(
953                        context.request_path,
954                        rm.consumed_segments,
955                    ));
956                }
957
958                context.route_children = route_frag.children.clone();
959
960                let comp = webui_protocol::WebUIFragmentComponent {
961                    fragment_id: route_frag.fragment_id.clone(),
962                };
963
964                context.writer.write("<")?;
965                context.writer.write(&route_frag.fragment_id)?;
966                if let Some(p) = &context.plugin {
967                    p.write_route_component_state(context.state, context.writer)?;
968                }
969                context.writer.write(">")?;
970
971                self.process_component(&comp, context)?;
972
973                context.writer.write("</")?;
974                context.writer.write(&route_frag.fragment_id)?;
975                context.writer.write(">")?;
976
977                context.route_base = saved_route_base;
978                context.route_children = saved_route_children;
979            }
980        } else {
981            context.writer.write(" style=\"display:none\">")?;
982        }
983
984        context.writer.write("</webui-route>")?;
985        Ok(())
986    }
987
988    /// Process a component fragment.
989    fn process_component(
990        &self,
991        component: &webui_protocol::WebUIFragmentComponent,
992        context: &mut WebUIProcessContext,
993    ) -> Result<()> {
994        // Emit the component's CSS module importmap into its light DOM
995        // on first encounter (see `emit_css_module`).
996        if !context.rendered_components.contains(&component.fragment_id) {
997            self.emit_css_module(component, context)?;
998        }
999
1000        // Track this component as rendered (for selective f-template emission)
1001        context
1002            .rendered_components
1003            .insert(component.fragment_id.clone());
1004
1005        // Save parent scope
1006        let saved_local_vars = std::mem::take(&mut context.local_vars);
1007        let saved_component_attrs = std::mem::take(&mut context.component_attrs);
1008
1009        // Component gets accumulated attrs as its local vars.
1010        context.local_vars = saved_component_attrs;
1011
1012        if let Some(p) = &mut context.plugin {
1013            p.push_scope();
1014        }
1015
1016        self.process_fragment_id(&component.fragment_id, context)?;
1017
1018        if let Some(p) = &mut context.plugin {
1019            p.pop_scope();
1020        }
1021
1022        // Restore parent scope
1023        context.local_vars = saved_local_vars;
1024        context.component_attrs = HashMap::new();
1025
1026        Ok(())
1027    }
1028
1029    /// Resolve a dotted path value, checking local variables first, then global state.
1030    fn resolve_value(&self, path: &str, context: &WebUIProcessContext<'_>) -> Option<Value> {
1031        resolve_value_from_sources(path, &context.local_vars, context.state).map(Cow::into_owned)
1032    }
1033
1034    /// Evaluate a condition expression against the current context.
1035    ///
1036    /// Uses a resolver closure that checks local variables first, then falls
1037    /// back to global state — avoiding a full clone of the state tree.
1038    /// Returns false if the condition references a missing value.
1039    fn evaluate_condition(
1040        &self,
1041        condition: &webui_protocol::ConditionExpr,
1042        context: &WebUIProcessContext,
1043    ) -> Result<bool> {
1044        let local_vars = &context.local_vars;
1045        let state = context.state;
1046        match evaluate_with_resolver(condition, |path| {
1047            resolve_value_from_sources(path, local_vars, state)
1048        }) {
1049            Ok(result) => Ok(result),
1050            Err(ExpressionError::MissingValue(_)) => Ok(false),
1051            Err(e) => Err(HandlerError::Evaluation(e.to_string())),
1052        }
1053    }
1054
1055    /// Process a for loop fragment.
1056    ///
1057    /// Creates a new context for each iteration that includes the current loop item.
1058    /// This allows nested templates to access both the loop variable and any parent context.
1059    /// Example: `for item in items` makes "item" available in the loop body.
1060    fn process_for_loop(
1061        &self,
1062        for_loop: &webui_protocol::WebUIFragmentFor,
1063        context: &mut WebUIProcessContext,
1064    ) -> Result<()> {
1065        let collection_name = &for_loop.collection;
1066
1067        // If the collection is missing, treat it as empty (0 iterations) — matches NodeJS behavior.
1068        // Hydration comments are always emitted regardless of collection presence.
1069        let items = match self.resolve_value(collection_name, context) {
1070            Some(Value::Array(arr)) => arr,
1071            Some(_) => {
1072                return Err(HandlerError::TypeError(format!(
1073                    "Collection '{}' is not an array",
1074                    collection_name
1075                )))
1076            }
1077            None => Vec::new(),
1078        };
1079
1080        if let Some(p) = &mut context.plugin {
1081            p.on_for_start(&for_loop.fragment_id, context.writer)?;
1082        }
1083
1084        // Hot-loop optimisation: the loop variable name is `String`-keyed
1085        // in `local_vars`. The naive impl re-inserts (and so re-allocates
1086        // the key) on every iteration — a 1000-item loop pays 2000 String
1087        // clones for the key alone. Instead, we save the outer-scope
1088        // value (if any) ONCE before the loop, install the key ONCE with
1089        // an empty placeholder, then overwrite the value in-place each
1090        // iteration via `get_mut`. Restoration at the end happens once.
1091        let item_name = for_loop.item.as_str();
1092        let saved_value = context.local_vars.remove(item_name);
1093        // Pre-insert the key so per-iteration `get_mut` is infallible.
1094        // Cost: at most one `String::from(item_name)` for the lifetime
1095        // of the loop, regardless of iteration count.
1096        if !items.is_empty() {
1097            context
1098                .local_vars
1099                .insert(item_name.to_string(), Value::Null);
1100        }
1101        for (i, item) in items.into_iter().enumerate() {
1102            if let Some(p) = &mut context.plugin {
1103                p.on_repeat_item_start(i, context.writer)?;
1104                p.push_scope();
1105            }
1106
1107            // O(1) value swap; no key allocation.
1108            if let Some(slot) = context.local_vars.get_mut(item_name) {
1109                *slot = item;
1110            }
1111            self.process_fragment_id(&for_loop.fragment_id, context)?;
1112
1113            if let Some(p) = &mut context.plugin {
1114                p.pop_scope();
1115                p.on_repeat_item_end(i, context.writer)?;
1116            }
1117        }
1118        // Restore outer scope (or remove the placeholder we installed).
1119        match saved_value {
1120            Some(value) => {
1121                context.local_vars.insert(item_name.to_string(), value);
1122            }
1123            None => {
1124                context.local_vars.remove(item_name);
1125            }
1126        }
1127
1128        if let Some(p) = &mut context.plugin {
1129            p.on_for_end(&for_loop.fragment_id, context.writer)?;
1130        }
1131
1132        Ok(())
1133    }
1134
1135    /// Process a signal fragment.
1136    ///
1137    /// Looks up the value in the context first (for local variables), then in the global state.
1138    /// This prioritization allows local variables (like loop items) to override global state.
1139    /// If the value is not found in either scope, an empty string is returned.
1140    fn process_signal(
1141        &self,
1142        signal: &webui_protocol::WebUIFragmentSignal,
1143        context: &mut WebUIProcessContext,
1144    ) -> Result<()> {
1145        // Hook: emit nonce meta and CSS <link> tags before </head>.
1146        // Guarded by `head_end_emitted` so a malformed protocol cannot
1147        // emit nonce/preloads/inject more than once per render.
1148        if signal.raw && signal.value == "head_end" && !context.head_end_emitted {
1149            context.head_end_emitted = true;
1150            if let Some(nonce) = context.nonce {
1151                context
1152                    .writer
1153                    .write("<meta name=\"webui-nonce\" content=\"")?;
1154                context
1155                    .writer
1156                    .write(&crate::html_encode::encode_safe(nonce))?;
1157                context.writer.write("\">")?;
1158            }
1159
1160            // Emit CSS <link> tags in <head> for Link-strategy components.
1161            // For components with a non-empty css_href:
1162            //   Link + Shadow → <link rel="preload"> (stylesheet is in shadow root)
1163            //   Link + Light  → <link rel="stylesheet"> (no shadow root)
1164            //
1165            // Style and Module strategies emit their CSS during component
1166            // rendering (shadow-DOM template / importmap respectively).
1167            let is_link = context.protocol.css_strategy() == webui_protocol::CssStrategy::Link;
1168            let is_shadow = context.protocol.dom_strategy() == webui_protocol::DomStrategy::Shadow;
1169
1170            if is_link {
1171                let (needed_components, _) =
1172                    crate::route_handler::get_needed_components_for_request(
1173                        context.protocol,
1174                        context.entry_id,
1175                        context.request_path,
1176                        "",
1177                        (context.component_index, context.route_index),
1178                    )?;
1179
1180                for name in &needed_components {
1181                    if let Some(href) = context
1182                        .protocol
1183                        .components
1184                        .get(name)
1185                        .map(|c| c.css_href.as_str())
1186                        .filter(|h| !h.is_empty())
1187                    {
1188                        if is_shadow {
1189                            context.writer.write("<link rel=\"preload\" href=\"")?;
1190                            context.writer.write(href)?;
1191                            context
1192                                .writer
1193                                .write("\" as=\"style\" data-webui-ssr-preload=\"style\">")?;
1194                        } else {
1195                            context.writer.write("<link rel=\"stylesheet\" href=\"")?;
1196                            context.writer.write(href)?;
1197                            context.writer.write("\">")?;
1198                        }
1199                    }
1200                }
1201            }
1202
1203            // Per-render `head_inject` HTML — image preloads, A/B test
1204            // markers, etc. supplied by the host via RenderOptions.
1205            // Emitted at the structural head_end boundary, after the
1206            // built-in nonce + CSS-link emissions, so host injects
1207            // appear immediately before `</head>`.
1208            if let Some(html) = context.head_inject {
1209                context.writer.write(html)?;
1210            }
1211        }
1212
1213        // Hook: emit component templates and host body_inject before </body>.
1214        // Single guarded block so the dedup flag protects both the
1215        // hydration emission and the host inject from a malformed
1216        // protocol that fires `body_end` more than once per render.
1217        if signal.raw && signal.value == "body_end" && !context.body_end_emitted {
1218            context.body_end_emitted = true;
1219            if context.plugin.is_some() {
1220                // Emit templates for all REACHABLE components on the current route,
1221                // not just those rendered in this SSR pass. Components inside false
1222                // <if> blocks or empty <for> loops are reachable via client-side
1223                // state changes and need their templates available without a server
1224                // round-trip. The graph walker follows conditional and loop branches
1225                // unconditionally, but only descends into the matched route chain —
1226                // components on other routes are delivered via SPA partial navigation.
1227                let reachable = crate::route_handler::collect_reachable_components_for_request(
1228                    context.protocol,
1229                    context.entry_id,
1230                    context.request_path,
1231                    context.route_index,
1232                );
1233                let state_selection =
1234                    collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
1235
1236                // Emit CSS module importmaps for reachable-but-unrendered
1237                // components so the framework can adopt them when an `<if>`
1238                // condition flips true client-side.
1239                for name in &reachable {
1240                    if !context.rendered_components.contains(name) {
1241                        if let Some(css) = context
1242                            .protocol
1243                            .components
1244                            .get(name)
1245                            .map(|c| c.css.as_str())
1246                            .filter(|s| !s.is_empty())
1247                        {
1248                            self.emit_css_module_importmap(name, css, context)?;
1249                        }
1250                    }
1251                }
1252
1253                // Try to collect split WebUI template payloads. If the plugin
1254                // returns None (non-WebUI templates, e.g. FAST), fall back to
1255                // separate emission.
1256                let template_payloads = context
1257                    .plugin
1258                    .as_ref()
1259                    .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
1260
1261                if template_payloads.is_none() {
1262                    // Non-JS templates (FAST plugins) - emit separately
1263                    if let Some(ref p) = context.plugin {
1264                        p.emit_templates(
1265                            context.protocol,
1266                            &reachable,
1267                            context.nonce,
1268                            context.writer,
1269                        )?;
1270                    }
1271                }
1272
1273                // Compute the inventory hex from actually rendered components.
1274                let inventory_hex = crate::route_handler::encode_component_inventory(
1275                    &context.rendered_components,
1276                    context.component_index,
1277                );
1278
1279                // Chain
1280                let chain = crate::route_handler::collect_route_chain(
1281                    context.protocol,
1282                    context.entry_id,
1283                    context.request_path,
1284                    context.route_index,
1285                );
1286                let chain_json: Vec<Value> = chain
1287                    .iter()
1288                    .map(crate::route_handler::RouteChainEntry::to_json)
1289                    .collect();
1290
1291                // CSS hrefs emitted during SSR (Link-strategy components)
1292                let is_link = context.protocol.css_strategy() == webui_protocol::CssStrategy::Link;
1293                let mut css_hrefs: Vec<&str> = Vec::new();
1294                if is_link {
1295                    for name in &reachable {
1296                        if let Some(href) = context
1297                            .protocol
1298                            .components
1299                            .get(name)
1300                            .map(|c| c.css_href.as_str())
1301                            .filter(|h| !h.is_empty())
1302                        {
1303                            css_hrefs.push(href);
1304                        }
1305                    }
1306                }
1307
1308                // Module style specifiers emitted during SSR
1309                let mut style_specs: Vec<&str> = Vec::new();
1310                for name in &reachable {
1311                    if context
1312                        .protocol
1313                        .components
1314                        .get(name)
1315                        .map(|c| !c.css.is_empty())
1316                        .unwrap_or(false)
1317                    {
1318                        style_specs.push(name);
1319                    }
1320                }
1321
1322                let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
1323                let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
1324                write_webui_data_block(
1325                    context.writer,
1326                    WebUiBootstrap {
1327                        state: context.state,
1328                        state_selection,
1329                        chain: &chain_json,
1330                        inventory: &inventory_hex,
1331                        nonce: context.nonce,
1332                        css_hrefs: &css_hrefs,
1333                        style_specs: &style_specs,
1334                        templates: payloads,
1335                    },
1336                )?;
1337
1338                // Let the active plugin emit any framework-specific executable
1339                // side channel. FAST plugins default to no-op; WebUI installs
1340                // templateFns. Client packages parse #webui-data lazily.
1341                if let Some(ref plugin) = context.plugin {
1342                    plugin.emit_bootstrap_extension(
1343                        BootstrapExtensionContext {
1344                            protocol: context.protocol,
1345                            components: &reachable,
1346                            payloads,
1347                            nonce: context.nonce,
1348                        },
1349                        context.writer,
1350                    )?;
1351                }
1352            }
1353
1354            // Per-render `body_inject` HTML — dev livereload script,
1355            // analytics, etc. supplied by the host via RenderOptions.
1356            // Inside the dedup block but outside the plugin-only
1357            // sub-block above, so it fires regardless of whether a
1358            // hydration plugin is active. Appears immediately before
1359            // `</body>`.
1360            if let Some(html) = context.body_inject {
1361                context.writer.write(html)?;
1362            }
1363        }
1364
1365        if let Some(p) = &mut context.plugin {
1366            p.on_binding_start(&signal.value, context.writer)?;
1367        }
1368
1369        if let Some(value) = self.resolve_value(&signal.value, context) {
1370            self.write_signal_value(&value, signal.raw, context.writer)?;
1371        }
1372
1373        if let Some(p) = &mut context.plugin {
1374            p.on_binding_end(&signal.value, context.writer)?;
1375        }
1376        Ok(())
1377    }
1378
1379    /// Write a signal value directly to the writer, avoiding intermediate String allocation.
1380    /// For HTML-escaped output, writes the Cow from `encode_safe` directly.
1381    fn write_signal_value(
1382        &self,
1383        value: &Value,
1384        raw: bool,
1385        writer: &mut dyn ResponseWriter,
1386    ) -> Result<()> {
1387        if raw {
1388            match value {
1389                Value::String(s) => writer.write(s),
1390                _ => writer.write(&value.to_string()),
1391            }
1392        } else {
1393            match value {
1394                Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
1395                _ => {
1396                    let s = value.to_string();
1397                    writer.write(&crate::html_encode::encode_safe(&s))
1398                }
1399            }
1400        }
1401    }
1402
1403    /// Process an if condition fragment.
1404    fn process_if(
1405        &self,
1406        if_cond: &webui_protocol::WebUIFragmentIf,
1407        context: &mut WebUIProcessContext,
1408    ) -> Result<()> {
1409        let condition = if_cond
1410            .condition
1411            .as_ref()
1412            .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
1413        let condition_met = self.evaluate_condition(condition, context)?;
1414
1415        if let Some(p) = &mut context.plugin {
1416            p.on_if_start(&if_cond.fragment_id, context.writer)?;
1417        }
1418
1419        if condition_met {
1420            if let Some(p) = &mut context.plugin {
1421                p.push_scope();
1422            }
1423
1424            self.process_fragment_id(&if_cond.fragment_id, context)?;
1425
1426            if let Some(p) = &mut context.plugin {
1427                p.pop_scope();
1428            }
1429        }
1430
1431        if let Some(p) = &mut context.plugin {
1432            p.on_if_end(&if_cond.fragment_id, context.writer)?;
1433        }
1434
1435        Ok(())
1436    }
1437
1438    /// Process an attribute fragment by rendering the attribute name/value pair.
1439    fn process_attribute(
1440        &self,
1441        attr: &webui_protocol::WebUIFragmentAttribute,
1442        context: &mut WebUIProcessContext,
1443    ) -> Result<()> {
1444        // Initialize component attribute accumulator on attrStart
1445        if attr.attr_start {
1446            context.component_attrs = HashMap::new();
1447        }
1448
1449        // Boolean attribute with condition tree
1450        if let Some(condition) = &attr.condition_tree {
1451            let condition_met = self.evaluate_condition(condition, context)?;
1452
1453            if !attr.attr_skip {
1454                let name = component_attr_name(&attr.name);
1455                context
1456                    .component_attrs
1457                    .insert(name, Value::Bool(condition_met));
1458            }
1459
1460            if condition_met {
1461                context.writer.write(" ")?;
1462                context.writer.write(&attr.name)?;
1463            }
1464            return Ok(());
1465        }
1466
1467        // Template attribute (mixed static + dynamic)
1468        if !attr.template.is_empty() {
1469            let raw_value = self.render_template_attr_value(&attr.template, context)?;
1470            let escaped = crate::html_encode::encode_safe(&raw_value);
1471            write_attr(context.writer, &attr.name, &escaped)?;
1472
1473            if !attr.attr_skip {
1474                let name = component_attr_name(&attr.name);
1475                context
1476                    .component_attrs
1477                    .insert(name, Value::String(raw_value));
1478            }
1479            return Ok(());
1480        }
1481
1482        // Simple attribute
1483        if !attr.value.is_empty() {
1484            if attr.raw_value {
1485                // Static attribute — value is the literal string
1486                write_attr(context.writer, &attr.name, &attr.value)?;
1487                if !attr.attr_skip {
1488                    let name = component_attr_name(&attr.name);
1489                    context
1490                        .component_attrs
1491                        .insert(name, Value::String(attr.value.clone()));
1492                }
1493            } else if attr.complex {
1494                // Complex attribute — resolve value, don't render to HTML, store as state
1495                if let Some(value) = self.resolve_value(&attr.value, context) {
1496                    if !attr.attr_skip {
1497                        let stripped = attr.name.strip_prefix(':').unwrap_or(&attr.name);
1498                        let name = component_attr_name(stripped);
1499                        context.component_attrs.insert(name, value);
1500                    }
1501                }
1502            } else {
1503                // Dynamic attribute — resolve and render
1504                let value = self.resolve_value(&attr.value, context);
1505                // Always emit the attribute so FAST hydration markers
1506                // (`data-fe`) match the DOM node structure.
1507                match &value {
1508                    Some(Value::String(s)) => {
1509                        write_attr(
1510                            context.writer,
1511                            &attr.name,
1512                            &crate::html_encode::encode_safe(s),
1513                        )?;
1514                    }
1515                    Some(Value::Null) | None => {
1516                        write_attr(context.writer, &attr.name, "")?;
1517                    }
1518                    Some(other) => {
1519                        let s = other.to_string();
1520                        write_attr(
1521                            context.writer,
1522                            &attr.name,
1523                            &crate::html_encode::encode_safe(&s),
1524                        )?;
1525                    }
1526                }
1527
1528                if !attr.attr_skip {
1529                    let name = component_attr_name(&attr.name);
1530                    context
1531                        .component_attrs
1532                        .insert(name, value.unwrap_or(Value::String(String::new())));
1533                }
1534            }
1535        }
1536
1537        Ok(())
1538    }
1539
1540    /// Render a template attribute's fragments into a raw (unescaped) string.
1541    fn render_template_attr_value(
1542        &self,
1543        template_id: &str,
1544        context: &WebUIProcessContext,
1545    ) -> Result<String> {
1546        let fragments = context
1547            .protocol
1548            .fragments
1549            .get(template_id)
1550            .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
1551        let mut raw_value = String::new();
1552        for frag in &fragments.fragments {
1553            match frag.fragment.as_ref() {
1554                Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
1555                Some(Fragment::Signal(signal)) => {
1556                    if let Some(value) = self.resolve_value(&signal.value, context) {
1557                        match &value {
1558                            Value::String(s) => raw_value.push_str(s),
1559                            _ => raw_value.push_str(&value.to_string()),
1560                        }
1561                    }
1562                }
1563                _ => {}
1564            }
1565        }
1566        Ok(raw_value)
1567    }
1568
1569    /// Render the UI based on the protocol and state.
1570    pub fn render<'a>(
1571        &self,
1572        protocol: &'a Protocol,
1573        state: &'a Value,
1574        options: &RenderOptions<'a>,
1575        writer: &'a mut dyn ResponseWriter,
1576    ) -> Result<()> {
1577        let document = protocol.protocol();
1578        if !document.fragments.contains_key(options.entry_id) {
1579            return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
1580        }
1581        let mut context = WebUIProcessContext {
1582            protocol: document,
1583            state,
1584            writer,
1585            local_vars: HashMap::new(),
1586            component_attrs: HashMap::new(),
1587            request_path: options.request_path,
1588            route_base: Cow::Borrowed("/"),
1589            rendered_components: HashSet::new(),
1590            plugin: self.plugin_factory.map(|f| f()),
1591            route_children: Vec::new(),
1592            entry_id: options.entry_id,
1593            // Same defensive normalisation as `handle()`. See the
1594            // doc-comment there for the CSP-outage rationale.
1595            nonce: options.nonce.filter(|s| !s.is_empty()),
1596            head_inject: options.head_inject.filter(|s| !s.is_empty()),
1597            body_inject: options.body_inject.filter(|s| !s.is_empty()),
1598            head_end_emitted: false,
1599            component_index: protocol.component_index(),
1600            body_end_emitted: false,
1601            route_index: protocol.route_index(),
1602            route_chain_index: 0,
1603        };
1604
1605        self.process_fragment_id(options.entry_id, &mut context)?;
1606        writer.end()?;
1607
1608        Ok(())
1609    }
1610}
1611
1612impl Default for WebUIHandler {
1613    fn default() -> Self {
1614        Self::new()
1615    }
1616}
1617
1618/// Write ` name="value"` to the writer without allocating a format string.
1619fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
1620    writer.write(" ")?;
1621    writer.write(name)?;
1622    writer.write("=\"")?;
1623    writer.write(value)?;
1624    writer.write("\"")
1625}
1626
1627#[cfg(test)]
1628fn handle(
1629    protocol: &WebUIProtocol,
1630    state: &Value,
1631    options: &RenderOptions<'_>,
1632    writer: &mut dyn ResponseWriter,
1633) -> Result<()> {
1634    let handler = WebUIHandler::new();
1635    handler.handle(protocol, state, options, writer)
1636}
1637
1638#[cfg(test)]
1639mod tests {
1640    use super::*;
1641    use std::cell::RefCell;
1642    use webui_protocol::{
1643        web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
1644        WebUIFragmentAttribute,
1645    };
1646    use webui_test_utils::test_json;
1647
1648    // A simple test writer implementation
1649    struct TestWriter {
1650        content: RefCell<String>,
1651        ended: RefCell<bool>,
1652    }
1653
1654    impl TestWriter {
1655        fn new() -> Self {
1656            Self {
1657                content: RefCell::new(String::new()),
1658                ended: RefCell::new(false),
1659            }
1660        }
1661
1662        fn get_content(&self) -> String {
1663            self.content.borrow().clone()
1664        }
1665
1666        fn is_ended(&self) -> bool {
1667            *self.ended.borrow()
1668        }
1669    }
1670
1671    impl ResponseWriter for TestWriter {
1672        fn write(&mut self, content: &str) -> Result<()> {
1673            self.content.borrow_mut().push_str(content);
1674            Ok(())
1675        }
1676
1677        fn end(&mut self) -> Result<()> {
1678            *self.ended.borrow_mut() = true;
1679            Ok(())
1680        }
1681    }
1682
1683    #[test]
1684    fn test_handle_raw() {
1685        // Create a simple protocol
1686        let mut fragments = HashMap::new();
1687        fragments.insert(
1688            "index.html".to_string(),
1689            FragmentList {
1690                fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
1691            },
1692        );
1693
1694        let protocol = WebUIProtocol::new(fragments);
1695        let state = test_json!({});
1696
1697        // Create a test writer
1698        let mut writer = TestWriter::new();
1699
1700        // Handle the protocol
1701        assert!(
1702            handle(
1703                &protocol,
1704                &state,
1705                &RenderOptions::new("index.html", "/"),
1706                &mut writer
1707            )
1708            .is_ok(),
1709            "Failed to handle raw protocol"
1710        );
1711
1712        // Check the output
1713        assert_eq!(writer.get_content(), "Hello, WebUI!");
1714        assert!(writer.is_ended());
1715    }
1716
1717    #[test]
1718    fn test_handle_signal() {
1719        // Create a protocol with a signal
1720        let mut fragments = HashMap::new();
1721        fragments.insert(
1722            "index.html".to_string(),
1723            FragmentList {
1724                fragments: vec![
1725                    WebUIFragment::raw("Hello, "),
1726                    WebUIFragment::signal("name", false),
1727                    WebUIFragment::raw("!"),
1728                ],
1729            },
1730        );
1731
1732        let protocol = WebUIProtocol::new(fragments);
1733        let state = test_json!({"name": "WebUI"});
1734
1735        // Create a test writer
1736        let mut writer = TestWriter::new();
1737
1738        // Handle the protocol
1739        assert!(
1740            handle(
1741                &protocol,
1742                &state,
1743                &RenderOptions::new("index.html", "/"),
1744                &mut writer
1745            )
1746            .is_ok(),
1747            "Failed to handle signal protocol"
1748        );
1749
1750        // Check the output
1751        assert_eq!(writer.get_content(), "Hello, WebUI!");
1752        assert!(writer.is_ended());
1753    }
1754
1755    #[test]
1756    fn test_handle_for_loop() {
1757        // Create a protocol with a for loop
1758        let mut fragments = HashMap::new();
1759        fragments.insert(
1760            "index.html".to_string(),
1761            FragmentList {
1762                fragments: vec![
1763                    WebUIFragment::raw("People: "),
1764                    WebUIFragment::for_loop("person", "people", "person-item"),
1765                ],
1766            },
1767        );
1768
1769        fragments.insert(
1770            "person-item".to_string(),
1771            FragmentList {
1772                fragments: vec![
1773                    WebUIFragment::signal("person.name", false),
1774                    WebUIFragment::raw(", "),
1775                ],
1776            },
1777        );
1778
1779        let protocol = WebUIProtocol::new(fragments);
1780        let state = test_json!({
1781            "people": [
1782                {"name": "Alice"},
1783                {"name": "Bob"},
1784                {"name": "Charlie"}
1785            ]
1786        });
1787
1788        // Create a test writer
1789        let mut writer = TestWriter::new();
1790
1791        // Handle the protocol
1792        assert!(
1793            handle(
1794                &protocol,
1795                &state,
1796                &RenderOptions::new("index.html", "/"),
1797                &mut writer
1798            )
1799            .is_ok(),
1800            "Failed to handle for loop protocol"
1801        );
1802
1803        // Check the output
1804        assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
1805        assert!(writer.is_ended());
1806    }
1807
1808    #[test]
1809    fn test_handle_if_condition() {
1810        // Create a protocol with an if condition
1811        let mut fragments = HashMap::new();
1812        fragments.insert(
1813            "index.html".to_string(),
1814            FragmentList {
1815                fragments: vec![
1816                    WebUIFragment::raw("Status: "),
1817                    WebUIFragment::if_cond(
1818                        webui_protocol::ConditionExpr::identifier("isActive"),
1819                        "active-content",
1820                    ),
1821                    WebUIFragment::raw("End"),
1822                ],
1823            },
1824        );
1825
1826        fragments.insert(
1827            "active-content".to_string(),
1828            FragmentList {
1829                fragments: vec![WebUIFragment::raw("Active")],
1830            },
1831        );
1832
1833        let protocol = WebUIProtocol::new(fragments);
1834
1835        // Test with isActive = true
1836        let state_true = test_json!({"isActive": true});
1837        let mut writer_true = TestWriter::new();
1838        assert!(
1839            handle(
1840                &protocol,
1841                &state_true,
1842                &RenderOptions::new("index.html", "/"),
1843                &mut writer_true
1844            )
1845            .is_ok(),
1846            "Failed to handle if condition (true case)"
1847        );
1848        assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
1849        assert!(writer_true.is_ended());
1850
1851        // Test with isActive = false
1852        let state_false = test_json!({"isActive": false});
1853        let mut writer_false = TestWriter::new();
1854        assert!(
1855            handle(
1856                &protocol,
1857                &state_false,
1858                &RenderOptions::new("index.html", "/"),
1859                &mut writer_false
1860            )
1861            .is_ok(),
1862            "Failed to handle if condition (false case)"
1863        );
1864        assert_eq!(writer_false.get_content(), "Status: End");
1865        assert!(writer_false.is_ended());
1866    }
1867
1868    #[test]
1869    fn test_handle_component() {
1870        // Create a protocol with a component
1871        let mut fragments = HashMap::new();
1872        fragments.insert(
1873            "index.html".to_string(),
1874            FragmentList {
1875                fragments: vec![
1876                    WebUIFragment::raw("Component: "),
1877                    WebUIFragment::component("my-component"),
1878                ],
1879            },
1880        );
1881
1882        fragments.insert(
1883            "my-component".to_string(),
1884            FragmentList {
1885                fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
1886            },
1887        );
1888
1889        let protocol = WebUIProtocol::new(fragments);
1890        let state = test_json!({});
1891
1892        // Create a test writer
1893        let mut writer = TestWriter::new();
1894
1895        // Handle the protocol
1896        assert!(
1897            handle(
1898                &protocol,
1899                &state,
1900                &RenderOptions::new("index.html", "/"),
1901                &mut writer
1902            )
1903            .is_ok(),
1904            "Failed to handle component protocol"
1905        );
1906
1907        // Check the output
1908        assert_eq!(
1909            writer.get_content(),
1910            "Component: <div>Component Content</div>"
1911        );
1912        assert!(writer.is_ended());
1913    }
1914
1915    #[test]
1916    fn test_missing_fragment() {
1917        // Create a protocol with a missing fragment reference
1918        let mut fragments = HashMap::new();
1919        fragments.insert(
1920            "index.html".to_string(),
1921            FragmentList {
1922                fragments: vec![WebUIFragment::component("missing-component")],
1923            },
1924        );
1925
1926        let protocol = WebUIProtocol::new(fragments);
1927        let state = test_json!({});
1928
1929        // Create a test writer
1930        let mut writer = TestWriter::new();
1931
1932        // Handle the protocol
1933        let result = handle(
1934            &protocol,
1935            &state,
1936            &RenderOptions::new("index.html", "/"),
1937            &mut writer,
1938        );
1939
1940        // Expect an error
1941        assert!(result.is_err());
1942        if let Err(HandlerError::MissingFragment(fragment_id)) = result {
1943            assert_eq!(fragment_id, "missing-component");
1944        } else {
1945            panic!("Expected MissingFragment error");
1946        }
1947    }
1948
1949    #[test]
1950    fn test_missing_signal_renders_empty() {
1951        // A signal referencing a field absent from state should render as empty
1952        let mut fragments = HashMap::new();
1953        fragments.insert(
1954            "index.html".to_string(),
1955            FragmentList {
1956                fragments: vec![
1957                    WebUIFragment::raw("Hello, "),
1958                    WebUIFragment::signal("missing_field", false),
1959                    WebUIFragment::raw("!"),
1960                ],
1961            },
1962        );
1963
1964        let protocol = WebUIProtocol::new(fragments);
1965        let state = test_json!({});
1966
1967        let mut writer = TestWriter::new();
1968
1969        assert!(
1970            handle(
1971                &protocol,
1972                &state,
1973                &RenderOptions::new("index.html", "/"),
1974                &mut writer
1975            )
1976            .is_ok(),
1977            "Missing signal should not produce an error"
1978        );
1979
1980        assert_eq!(writer.get_content(), "Hello, !");
1981        assert!(writer.is_ended());
1982    }
1983
1984    // ── Boolean attribute rendering tests ─────────────────────────────
1985
1986    #[test]
1987    fn test_boolean_attr_true() {
1988        let mut fragments = HashMap::new();
1989        fragments.insert(
1990            "index.html".to_string(),
1991            FragmentList {
1992                fragments: vec![
1993                    WebUIFragment::raw("<button"),
1994                    WebUIFragment::attribute_boolean(
1995                        "disabled",
1996                        ConditionExpr::identifier("isDisabled"),
1997                    ),
1998                    WebUIFragment::raw(">Click</button>"),
1999                ],
2000            },
2001        );
2002        let protocol = WebUIProtocol::new(fragments);
2003        let state = test_json!({"isDisabled": true});
2004        let mut writer = TestWriter::new();
2005        handle(
2006            &protocol,
2007            &state,
2008            &RenderOptions::new("index.html", "/"),
2009            &mut writer,
2010        )
2011        .unwrap();
2012        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
2013    }
2014
2015    #[test]
2016    fn test_boolean_attr_false() {
2017        let mut fragments = HashMap::new();
2018        fragments.insert(
2019            "index.html".to_string(),
2020            FragmentList {
2021                fragments: vec![
2022                    WebUIFragment::raw("<button"),
2023                    WebUIFragment::attribute_boolean(
2024                        "disabled",
2025                        ConditionExpr::identifier("isDisabled"),
2026                    ),
2027                    WebUIFragment::raw(">Click</button>"),
2028                ],
2029            },
2030        );
2031        let protocol = WebUIProtocol::new(fragments);
2032        let state = test_json!({"isDisabled": false});
2033        let mut writer = TestWriter::new();
2034        handle(
2035            &protocol,
2036            &state,
2037            &RenderOptions::new("index.html", "/"),
2038            &mut writer,
2039        )
2040        .unwrap();
2041        assert_eq!(writer.get_content(), "<button>Click</button>");
2042    }
2043
2044    #[test]
2045    fn test_boolean_attr_missing() {
2046        let mut fragments = HashMap::new();
2047        fragments.insert(
2048            "index.html".to_string(),
2049            FragmentList {
2050                fragments: vec![
2051                    WebUIFragment::raw("<input type=\"checkbox\""),
2052                    WebUIFragment::attribute_boolean(
2053                        "checked",
2054                        ConditionExpr::identifier("checked"),
2055                    ),
2056                    WebUIFragment::raw(">"),
2057                ],
2058            },
2059        );
2060        let protocol = WebUIProtocol::new(fragments);
2061        let state = test_json!({});
2062        let mut writer = TestWriter::new();
2063        handle(
2064            &protocol,
2065            &state,
2066            &RenderOptions::new("index.html", "/"),
2067            &mut writer,
2068        )
2069        .unwrap();
2070        assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
2071    }
2072
2073    #[test]
2074    fn test_boolean_attr_multiple() {
2075        let mut fragments = HashMap::new();
2076        fragments.insert(
2077            "index.html".to_string(),
2078            FragmentList {
2079                fragments: vec![
2080                    WebUIFragment::raw("<input type=\"checkbox\""),
2081                    WebUIFragment::attribute_boolean(
2082                        "checked",
2083                        ConditionExpr::identifier("checked"),
2084                    ),
2085                    WebUIFragment::attribute_boolean(
2086                        "disabled",
2087                        ConditionExpr::identifier("disabled"),
2088                    ),
2089                    WebUIFragment::raw(">"),
2090                ],
2091            },
2092        );
2093        let protocol = WebUIProtocol::new(fragments);
2094        let state = test_json!({"checked": true, "disabled": false});
2095        let mut writer = TestWriter::new();
2096        handle(
2097            &protocol,
2098            &state,
2099            &RenderOptions::new("index.html", "/"),
2100            &mut writer,
2101        )
2102        .unwrap();
2103        assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
2104    }
2105
2106    // ── Simple attribute rendering tests ──────────────────────────────
2107
2108    #[test]
2109    fn test_attribute_with_value() {
2110        let mut fragments = HashMap::new();
2111        fragments.insert(
2112            "index.html".to_string(),
2113            FragmentList {
2114                fragments: vec![
2115                    WebUIFragment::raw("<input"),
2116                    WebUIFragment::attribute("value", "inputValue"),
2117                    WebUIFragment::raw(">"),
2118                ],
2119            },
2120        );
2121        let protocol = WebUIProtocol::new(fragments);
2122        let state = test_json!({"inputValue": "Hello"});
2123        let mut writer = TestWriter::new();
2124        handle(
2125            &protocol,
2126            &state,
2127            &RenderOptions::new("index.html", "/"),
2128            &mut writer,
2129        )
2130        .unwrap();
2131        assert_eq!(writer.get_content(), "<input value=\"Hello\">");
2132    }
2133
2134    #[test]
2135    fn test_attribute_with_falsy_numeric() {
2136        let mut fragments = HashMap::new();
2137        fragments.insert(
2138            "index.html".to_string(),
2139            FragmentList {
2140                fragments: vec![
2141                    WebUIFragment::raw("<div name=\"test\""),
2142                    WebUIFragment::attribute("handle", "number"),
2143                    WebUIFragment::raw("></div>"),
2144                ],
2145            },
2146        );
2147        let protocol = WebUIProtocol::new(fragments);
2148        let state = test_json!({"number": 0});
2149        let mut writer = TestWriter::new();
2150        handle(
2151            &protocol,
2152            &state,
2153            &RenderOptions::new("index.html", "/"),
2154            &mut writer,
2155        )
2156        .unwrap();
2157        assert_eq!(
2158            writer.get_content(),
2159            "<div name=\"test\" handle=\"0\"></div>"
2160        );
2161    }
2162
2163    // ── Dynamic attribute escaping for non-string JSON types ─────────
2164
2165    #[test]
2166    fn test_attribute_array_value_is_escaped() {
2167        let mut fragments = HashMap::new();
2168        fragments.insert(
2169            "index.html".to_string(),
2170            FragmentList {
2171                fragments: vec![
2172                    WebUIFragment::raw("<a"),
2173                    WebUIFragment::attribute("href", "value"),
2174                    WebUIFragment::raw(">demo</a>"),
2175                ],
2176            },
2177        );
2178        let protocol = WebUIProtocol::new(fragments);
2179        let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
2180        let mut writer = TestWriter::new();
2181        handle(
2182            &protocol,
2183            &state,
2184            &RenderOptions::new("index.html", "/"),
2185            &mut writer,
2186        )
2187        .unwrap();
2188        let content = writer.get_content();
2189        // All inner double quotes must be entity-escaped so that the
2190        // browser never sees a second attribute boundary.
2191        assert!(
2192            content.contains("&quot;"),
2193            "Double quotes inside attribute value must be escaped: {content}"
2194        );
2195        // The href attribute value must be a single contiguous quoted
2196        // string — no extra attributes should appear.
2197        assert_eq!(
2198            content.matches("=\"").count(),
2199            1,
2200            "Only one attribute assignment expected: {content}"
2201        );
2202    }
2203
2204    #[test]
2205    fn test_attribute_object_value_is_escaped() {
2206        let mut fragments = HashMap::new();
2207        fragments.insert(
2208            "index.html".to_string(),
2209            FragmentList {
2210                fragments: vec![
2211                    WebUIFragment::raw("<div"),
2212                    WebUIFragment::attribute("data-cfg", "cfg"),
2213                    WebUIFragment::raw("></div>"),
2214                ],
2215            },
2216        );
2217        let protocol = WebUIProtocol::new(fragments);
2218        let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
2219        let mut writer = TestWriter::new();
2220        handle(
2221            &protocol,
2222            &state,
2223            &RenderOptions::new("index.html", "/"),
2224            &mut writer,
2225        )
2226        .unwrap();
2227        let content = writer.get_content();
2228        assert!(
2229            content.contains("&quot;"),
2230            "Double quotes inside attribute value must be escaped: {content}"
2231        );
2232        assert_eq!(
2233            content.matches("=\"").count(),
2234            1,
2235            "Only one attribute assignment expected: {content}"
2236        );
2237    }
2238
2239    // ── Template attribute rendering tests ────────────────────────────
2240
2241    #[test]
2242    fn test_mixed_attribute_template() {
2243        let mut fragments = HashMap::new();
2244        fragments.insert(
2245            "index.html".to_string(),
2246            FragmentList {
2247                fragments: vec![
2248                    WebUIFragment::raw("<input"),
2249                    WebUIFragment::attribute_template("value", "attr-1"),
2250                    WebUIFragment::raw(">"),
2251                ],
2252            },
2253        );
2254        fragments.insert(
2255            "attr-1".to_string(),
2256            FragmentList {
2257                fragments: vec![
2258                    WebUIFragment::raw("hello "),
2259                    WebUIFragment::signal("item", false),
2260                ],
2261            },
2262        );
2263        let protocol = WebUIProtocol::new(fragments);
2264        let state = test_json!({"item": "world"});
2265        let mut writer = TestWriter::new();
2266        handle(
2267            &protocol,
2268            &state,
2269            &RenderOptions::new("index.html", "/"),
2270            &mut writer,
2271        )
2272        .unwrap();
2273        assert_eq!(writer.get_content(), "<input value=\"hello world\">");
2274    }
2275
2276    // ── Raw signal rendering test ─────────────────────────────────────
2277
2278    #[test]
2279    fn test_raw_signal_not_escaped() {
2280        let mut fragments = HashMap::new();
2281        fragments.insert(
2282            "index.html".to_string(),
2283            FragmentList {
2284                fragments: vec![
2285                    WebUIFragment::signal("html", false),
2286                    WebUIFragment::signal("html", true),
2287                ],
2288            },
2289        );
2290        let protocol = WebUIProtocol::new(fragments);
2291        let state = test_json!({"html": "<strong>hi</strong>"});
2292        let mut writer = TestWriter::new();
2293        handle(
2294            &protocol,
2295            &state,
2296            &RenderOptions::new("index.html", "/"),
2297            &mut writer,
2298        )
2299        .unwrap();
2300        assert_eq!(
2301            writer.get_content(),
2302            "&lt;strong&gt;hi&lt;&#x2F;strong&gt;<strong>hi</strong>"
2303        );
2304    }
2305
2306    // ── Nested for loop tests ─────────────────────────────────────────
2307
2308    #[test]
2309    fn test_nested_for_loop() {
2310        let mut fragments = HashMap::new();
2311        fragments.insert(
2312            "index.html".to_string(),
2313            FragmentList {
2314                fragments: vec![
2315                    WebUIFragment::raw("<div>"),
2316                    WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
2317                    WebUIFragment::raw("</div>"),
2318                ],
2319            },
2320        );
2321        fragments.insert(
2322            "outer".to_string(),
2323            FragmentList {
2324                fragments: vec![
2325                    WebUIFragment::raw("<div>"),
2326                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
2327                    WebUIFragment::raw("</div>"),
2328                ],
2329            },
2330        );
2331        fragments.insert(
2332            "inner".to_string(),
2333            FragmentList {
2334                fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
2335            },
2336        );
2337        let protocol = WebUIProtocol::new(fragments);
2338        let state = test_json!({
2339            "outerItems": [
2340                {"innerItems": [{"name": "A"}, {"name": "B"}]},
2341                {"innerItems": [{"name": "C"}]}
2342            ]
2343        });
2344        let mut writer = TestWriter::new();
2345        handle(
2346            &protocol,
2347            &state,
2348            &RenderOptions::new("index.html", "/"),
2349            &mut writer,
2350        )
2351        .unwrap();
2352        assert_eq!(
2353            writer.get_content(),
2354            "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
2355        );
2356    }
2357
2358    #[test]
2359    fn test_nested_for_with_signals() {
2360        let mut fragments = HashMap::new();
2361        fragments.insert(
2362            "index.html".to_string(),
2363            FragmentList {
2364                fragments: vec![
2365                    WebUIFragment::raw("<div>"),
2366                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
2367                    WebUIFragment::raw("</div>"),
2368                ],
2369            },
2370        );
2371        fragments.insert(
2372            "outerTemplate".to_string(),
2373            FragmentList {
2374                fragments: vec![
2375                    WebUIFragment::raw("<div>"),
2376                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
2377                    WebUIFragment::raw("</div>"),
2378                ],
2379            },
2380        );
2381        fragments.insert(
2382            "innerTemplate".to_string(),
2383            FragmentList {
2384                fragments: vec![
2385                    WebUIFragment::raw("<span>"),
2386                    WebUIFragment::signal("innerItem.name", false),
2387                    WebUIFragment::raw("</span>"),
2388                ],
2389            },
2390        );
2391        let protocol = WebUIProtocol::new(fragments);
2392        let state = test_json!({
2393            "outerItems": [
2394                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
2395                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
2396            ]
2397        });
2398        let mut writer = TestWriter::new();
2399        handle(
2400            &protocol,
2401            &state,
2402            &RenderOptions::new("index.html", "/"),
2403            &mut writer,
2404        )
2405        .unwrap();
2406        assert_eq!(
2407            writer.get_content(),
2408            "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
2409        );
2410    }
2411
2412    #[test]
2413    fn test_nested_for_with_global_state() {
2414        let mut fragments = HashMap::new();
2415        fragments.insert(
2416            "index.html".to_string(),
2417            FragmentList {
2418                fragments: vec![
2419                    WebUIFragment::raw("<div>"),
2420                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
2421                    WebUIFragment::raw("</div>"),
2422                ],
2423            },
2424        );
2425        fragments.insert(
2426            "outerTemplate".to_string(),
2427            FragmentList {
2428                fragments: vec![
2429                    WebUIFragment::raw("<div>"),
2430                    WebUIFragment::signal("globalOuter", false),
2431                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
2432                    WebUIFragment::raw("</div>"),
2433                ],
2434            },
2435        );
2436        fragments.insert(
2437            "innerTemplate".to_string(),
2438            FragmentList {
2439                fragments: vec![
2440                    WebUIFragment::raw("<span>"),
2441                    WebUIFragment::signal("innerItem.name", false),
2442                    WebUIFragment::signal("globalInner", false),
2443                    WebUIFragment::raw("</span>"),
2444                ],
2445            },
2446        );
2447        let protocol = WebUIProtocol::new(fragments);
2448        let state = test_json!({
2449            "globalOuter": "GO",
2450            "globalInner": "GI",
2451            "outerItems": [
2452                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
2453                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
2454            ]
2455        });
2456        let mut writer = TestWriter::new();
2457        handle(
2458            &protocol,
2459            &state,
2460            &RenderOptions::new("index.html", "/"),
2461            &mut writer,
2462        )
2463        .unwrap();
2464        assert_eq!(
2465            writer.get_content(),
2466            "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
2467        );
2468    }
2469
2470    // ── For + If state scoping tests ──────────────────────────────────
2471
2472    #[test]
2473    fn test_if_in_for_uses_local_state() {
2474        let mut fragments = HashMap::new();
2475        fragments.insert(
2476            "index.html".to_string(),
2477            FragmentList {
2478                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
2479            },
2480        );
2481        fragments.insert(
2482            "item-tpl".to_string(),
2483            FragmentList {
2484                fragments: vec![WebUIFragment::if_cond(
2485                    ConditionExpr::identifier("item.visible"),
2486                    "visible-tpl",
2487                )],
2488            },
2489        );
2490        fragments.insert(
2491            "visible-tpl".to_string(),
2492            FragmentList {
2493                fragments: vec![WebUIFragment::signal("item.name", false)],
2494            },
2495        );
2496        let protocol = WebUIProtocol::new(fragments);
2497        let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
2498        let mut writer = TestWriter::new();
2499        handle(
2500            &protocol,
2501            &state,
2502            &RenderOptions::new("index.html", "/"),
2503            &mut writer,
2504        )
2505        .unwrap();
2506        assert_eq!(writer.get_content(), "Show");
2507    }
2508
2509    #[test]
2510    fn test_for_if_local_overrides_global() {
2511        let mut fragments = HashMap::new();
2512        fragments.insert(
2513            "index.html".to_string(),
2514            FragmentList {
2515                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
2516            },
2517        );
2518        fragments.insert(
2519            "item-tpl".to_string(),
2520            FragmentList {
2521                fragments: vec![WebUIFragment::if_cond(
2522                    ConditionExpr::identifier("item.flag"),
2523                    "show-tpl",
2524                )],
2525            },
2526        );
2527        fragments.insert(
2528            "show-tpl".to_string(),
2529            FragmentList {
2530                fragments: vec![WebUIFragment::raw("yes")],
2531            },
2532        );
2533        let protocol = WebUIProtocol::new(fragments);
2534        // Global flag is true, but local item.flag is false for second item
2535        let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
2536        let mut writer = TestWriter::new();
2537        handle(
2538            &protocol,
2539            &state,
2540            &RenderOptions::new("index.html", "/"),
2541            &mut writer,
2542        )
2543        .unwrap();
2544        assert_eq!(writer.get_content(), "yes");
2545    }
2546
2547    // ── Component attribute state tests ───────────────────────────────
2548
2549    #[test]
2550    fn test_component_attr_state_simple() {
2551        let mut fragments = HashMap::new();
2552        fragments.insert(
2553            "index.html".to_string(),
2554            FragmentList {
2555                fragments: vec![
2556                    WebUIFragment::raw("<my-comp"),
2557                    WebUIFragment {
2558                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2559                            WebUIFragmentAttribute {
2560                                name: "title".into(),
2561                                value: "Attribute Title".into(),
2562                                attr_start: true,
2563                                raw_value: true,
2564                                ..Default::default()
2565                            },
2566                        )),
2567                    },
2568                    WebUIFragment::raw(">"),
2569                    WebUIFragment::component("my-comp"),
2570                    WebUIFragment::raw("</my-comp>"),
2571                ],
2572            },
2573        );
2574        fragments.insert(
2575            "my-comp".to_string(),
2576            FragmentList {
2577                fragments: vec![
2578                    WebUIFragment::raw("<span>"),
2579                    WebUIFragment::signal("title", false),
2580                    WebUIFragment::raw("</span>"),
2581                ],
2582            },
2583        );
2584        let protocol = WebUIProtocol::new(fragments);
2585        let state = test_json!({"title": "Global Title"});
2586        let mut writer = TestWriter::new();
2587        handle(
2588            &protocol,
2589            &state,
2590            &RenderOptions::new("index.html", "/"),
2591            &mut writer,
2592        )
2593        .unwrap();
2594        assert_eq!(
2595            writer.get_content(),
2596            "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
2597        );
2598    }
2599
2600    #[test]
2601    fn test_component_attr_state_template() {
2602        let mut fragments = HashMap::new();
2603        fragments.insert(
2604            "index.html".to_string(),
2605            FragmentList {
2606                fragments: vec![
2607                    WebUIFragment::raw("<my-comp"),
2608                    WebUIFragment {
2609                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2610                            WebUIFragmentAttribute {
2611                                name: "title".into(),
2612                                template: "title-attr".into(),
2613                                attr_start: true,
2614                                ..Default::default()
2615                            },
2616                        )),
2617                    },
2618                    WebUIFragment::raw(">"),
2619                    WebUIFragment::component("my-comp"),
2620                    WebUIFragment::raw("</my-comp>"),
2621                ],
2622            },
2623        );
2624        fragments.insert(
2625            "title-attr".to_string(),
2626            FragmentList {
2627                fragments: vec![
2628                    WebUIFragment::raw("hello "),
2629                    WebUIFragment::signal("item", false),
2630                ],
2631            },
2632        );
2633        fragments.insert(
2634            "my-comp".to_string(),
2635            FragmentList {
2636                fragments: vec![
2637                    WebUIFragment::raw("<span>"),
2638                    WebUIFragment::signal("title", false),
2639                    WebUIFragment::raw("</span>"),
2640                ],
2641            },
2642        );
2643        let protocol = WebUIProtocol::new(fragments);
2644        let state = test_json!({"item": "<world>"});
2645        let mut writer = TestWriter::new();
2646        handle(
2647            &protocol,
2648            &state,
2649            &RenderOptions::new("index.html", "/"),
2650            &mut writer,
2651        )
2652        .unwrap();
2653        assert_eq!(
2654            writer.get_content(),
2655            "<my-comp title=\"hello &lt;world&gt;\"><span>hello &lt;world&gt;</span></my-comp>"
2656        );
2657    }
2658
2659    #[test]
2660    fn test_component_attr_camel_case() {
2661        let mut fragments = HashMap::new();
2662        fragments.insert(
2663            "index.html".to_string(),
2664            FragmentList {
2665                fragments: vec![
2666                    WebUIFragment::raw("<my-comp"),
2667                    WebUIFragment {
2668                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2669                            WebUIFragmentAttribute {
2670                                name: "data-title".into(),
2671                                template: "dt-attr".into(),
2672                                attr_start: true,
2673                                ..Default::default()
2674                            },
2675                        )),
2676                    },
2677                    WebUIFragment::raw(">"),
2678                    WebUIFragment::component("my-comp"),
2679                    WebUIFragment::raw("</my-comp>"),
2680                ],
2681            },
2682        );
2683        fragments.insert(
2684            "dt-attr".to_string(),
2685            FragmentList {
2686                fragments: vec![
2687                    WebUIFragment::raw("prefix "),
2688                    WebUIFragment::signal("item", false),
2689                ],
2690            },
2691        );
2692        fragments.insert(
2693            "my-comp".to_string(),
2694            FragmentList {
2695                fragments: vec![
2696                    WebUIFragment::raw("<span>"),
2697                    WebUIFragment::signal("dataTitle", false),
2698                    WebUIFragment::raw("</span>"),
2699                ],
2700            },
2701        );
2702        let protocol = WebUIProtocol::new(fragments);
2703        let state = test_json!({"item": "a&b"});
2704        let mut writer = TestWriter::new();
2705        handle(
2706            &protocol,
2707            &state,
2708            &RenderOptions::new("index.html", "/"),
2709            &mut writer,
2710        )
2711        .unwrap();
2712        assert_eq!(
2713            writer.get_content(),
2714            "<my-comp data-title=\"prefix a&amp;b\"><span>prefix a&amp;b</span></my-comp>"
2715        );
2716    }
2717
2718    #[test]
2719    fn test_component_complex_attr() {
2720        let mut fragments = HashMap::new();
2721        fragments.insert(
2722            "index.html".to_string(),
2723            FragmentList {
2724                fragments: vec![
2725                    WebUIFragment::raw("<my-comp"),
2726                    WebUIFragment {
2727                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2728                            WebUIFragmentAttribute {
2729                                name: ":item".into(),
2730                                value: "complexItem".into(),
2731                                attr_start: true,
2732                                complex: true,
2733                                ..Default::default()
2734                            },
2735                        )),
2736                    },
2737                    WebUIFragment::raw(">"),
2738                    WebUIFragment::component("my-comp"),
2739                    WebUIFragment::raw("</my-comp>"),
2740                ],
2741            },
2742        );
2743        fragments.insert(
2744            "my-comp".to_string(),
2745            FragmentList {
2746                fragments: vec![
2747                    WebUIFragment::raw("<span>"),
2748                    WebUIFragment::signal("item.foo", false),
2749                    WebUIFragment::raw("</span><p>"),
2750                    WebUIFragment::signal("item.bar", false),
2751                    WebUIFragment::raw("</p>"),
2752                ],
2753            },
2754        );
2755        let protocol = WebUIProtocol::new(fragments);
2756        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
2757        let mut writer = TestWriter::new();
2758        handle(
2759            &protocol,
2760            &state,
2761            &RenderOptions::new("index.html", "/"),
2762            &mut writer,
2763        )
2764        .unwrap();
2765        assert_eq!(
2766            writer.get_content(),
2767            "<my-comp><span>1</span><p>true</p></my-comp>"
2768        );
2769    }
2770
2771    #[test]
2772    fn test_component_no_parent_pollution() {
2773        let mut fragments = HashMap::new();
2774        fragments.insert(
2775            "index.html".to_string(),
2776            FragmentList {
2777                fragments: vec![
2778                    WebUIFragment::raw("<parent"),
2779                    WebUIFragment {
2780                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2781                            WebUIFragmentAttribute {
2782                                name: "var".into(),
2783                                value: "var".into(),
2784                                attr_start: true,
2785                                ..Default::default()
2786                            },
2787                        )),
2788                    },
2789                    WebUIFragment::raw(">"),
2790                    WebUIFragment::component("parent"),
2791                    WebUIFragment::raw("</parent>"),
2792                ],
2793            },
2794        );
2795        fragments.insert(
2796            "parent".to_string(),
2797            FragmentList {
2798                fragments: vec![
2799                    WebUIFragment::raw("Before: "),
2800                    WebUIFragment::signal("var", false),
2801                    WebUIFragment::raw("<child foo"),
2802                    WebUIFragment {
2803                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2804                            WebUIFragmentAttribute {
2805                                name: "var".into(),
2806                                value: "replaced".into(),
2807                                raw_value: true,
2808                                ..Default::default()
2809                            },
2810                        )),
2811                    },
2812                    WebUIFragment::raw(">"),
2813                    WebUIFragment::component("child"),
2814                    WebUIFragment::raw("Label</child>After: "),
2815                    WebUIFragment::signal("var", false),
2816                ],
2817            },
2818        );
2819        fragments.insert("child".to_string(), FragmentList { fragments: vec![] });
2820        let protocol = WebUIProtocol::new(fragments);
2821        let state = test_json!({"var": "original"});
2822        let mut writer = TestWriter::new();
2823        handle(
2824            &protocol,
2825            &state,
2826            &RenderOptions::new("index.html", "/"),
2827            &mut writer,
2828        )
2829        .unwrap();
2830        assert_eq!(
2831            writer.get_content(),
2832            "<parent var=\"original\">Before: original<child foo var=\"replaced\">Label</child>After: original</parent>"
2833        );
2834    }
2835
2836    #[test]
2837    fn test_component_boolean_attr_state() {
2838        let mut fragments = HashMap::new();
2839        fragments.insert(
2840            "index.html".to_string(),
2841            FragmentList {
2842                fragments: vec![
2843                    WebUIFragment::raw("<my-comp"),
2844                    WebUIFragment {
2845                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2846                            WebUIFragmentAttribute {
2847                                name: "disabled".into(),
2848                                attr_start: true,
2849                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
2850                                ..Default::default()
2851                            },
2852                        )),
2853                    },
2854                    WebUIFragment::raw(">"),
2855                    WebUIFragment::component("my-comp"),
2856                    WebUIFragment::raw("</my-comp>"),
2857                ],
2858            },
2859        );
2860        fragments.insert(
2861            "my-comp".to_string(),
2862            FragmentList {
2863                fragments: vec![WebUIFragment::if_cond(
2864                    ConditionExpr::identifier("disabled"),
2865                    "show",
2866                )],
2867            },
2868        );
2869        fragments.insert(
2870            "show".to_string(),
2871            FragmentList {
2872                fragments: vec![WebUIFragment::raw("disabled!")],
2873            },
2874        );
2875        let protocol = WebUIProtocol::new(fragments);
2876        let state = test_json!({"isDisabled": true});
2877        let mut writer = TestWriter::new();
2878        handle(
2879            &protocol,
2880            &state,
2881            &RenderOptions::new("index.html", "/"),
2882            &mut writer,
2883        )
2884        .unwrap();
2885        assert_eq!(
2886            writer.get_content(),
2887            "<my-comp disabled>disabled!</my-comp>"
2888        );
2889    }
2890
2891    // ===== HTML Escape Tests (ported from utils.test.js escapeHtml) =====
2892
2893    /// Helper: render a signal value through the handler and return the escaped output.
2894    fn render_signal(value: &str) -> String {
2895        let mut fragments = HashMap::new();
2896        fragments.insert(
2897            "index.html".to_string(),
2898            FragmentList {
2899                fragments: vec![WebUIFragment::signal("v", false)],
2900            },
2901        );
2902        let protocol = WebUIProtocol::new(fragments);
2903        let state = test_json!({"v": value});
2904        let mut writer = TestWriter::new();
2905        handle(
2906            &protocol,
2907            &state,
2908            &RenderOptions::new("index.html", "/"),
2909            &mut writer,
2910        )
2911        .unwrap();
2912        writer.get_content()
2913    }
2914
2915    #[test]
2916    fn test_escape_ampersand() {
2917        assert_eq!(render_signal("&"), "&amp;");
2918    }
2919
2920    #[test]
2921    fn test_escape_less_than() {
2922        assert_eq!(render_signal("<"), "&lt;");
2923    }
2924
2925    #[test]
2926    fn test_escape_greater_than() {
2927        assert_eq!(render_signal(">"), "&gt;");
2928    }
2929
2930    #[test]
2931    fn test_escape_double_quote() {
2932        assert_eq!(render_signal("\""), "&quot;");
2933    }
2934
2935    #[test]
2936    fn test_escape_single_quote() {
2937        // encode_safe escapes ' as &#x27;
2938        let result = render_signal("'");
2939        assert_eq!(
2940            result, "&#x27;",
2941            "Expected &#x27; for single quote, got: {result}"
2942        );
2943    }
2944
2945    #[test]
2946    fn test_escape_multiple_special_chars() {
2947        let result = render_signal("<script>alert('xss');</script>");
2948        assert!(
2949            result.contains("&lt;") && result.contains("&gt;"),
2950            "Expected escaped HTML, got: {}",
2951            result
2952        );
2953        assert!(
2954            !result.contains("<script>"),
2955            "Should not contain raw <script> tag"
2956        );
2957    }
2958
2959    #[test]
2960    fn test_escape_no_special_chars() {
2961        assert_eq!(render_signal("Hello World"), "Hello World");
2962    }
2963
2964    #[test]
2965    fn test_escape_empty_string() {
2966        assert_eq!(render_signal(""), "");
2967    }
2968
2969    #[test]
2970    fn test_escape_special_at_beginning() {
2971        let result = render_signal("<Hello");
2972        assert!(
2973            result.starts_with("&lt;"),
2974            "Expected &lt; at start, got: {}",
2975            result
2976        );
2977    }
2978
2979    #[test]
2980    fn test_escape_special_at_end() {
2981        let result = render_signal("Hello>");
2982        assert!(
2983            result.ends_with("&gt;"),
2984            "Expected &gt; at end, got: {}",
2985            result
2986        );
2987    }
2988
2989    #[test]
2990    fn test_escape_special_in_middle() {
2991        let result = render_signal("Hel&lo");
2992        assert!(
2993            result.contains("&amp;"),
2994            "Expected &amp; in middle, got: {}",
2995            result
2996        );
2997    }
2998
2999    // ── GROUP 5: Boolean Attribute Edge Cases ─────────────────────────
3000
3001    #[test]
3002    fn test_boolean_attr_truthy_values() {
3003        // checked: 1
3004        {
3005            let mut fragments = HashMap::new();
3006            fragments.insert(
3007                "index.html".to_string(),
3008                FragmentList {
3009                    fragments: vec![
3010                        WebUIFragment::raw("<input"),
3011                        WebUIFragment::attribute_boolean(
3012                            "checked",
3013                            ConditionExpr::identifier("checked"),
3014                        ),
3015                        WebUIFragment::raw(">"),
3016                    ],
3017                },
3018            );
3019            let protocol = WebUIProtocol::new(fragments);
3020            let state = test_json!({"checked": 1});
3021            let mut writer = TestWriter::new();
3022            handle(
3023                &protocol,
3024                &state,
3025                &RenderOptions::new("index.html", "/"),
3026                &mut writer,
3027            )
3028            .unwrap();
3029            assert_eq!(writer.get_content(), "<input checked>");
3030        }
3031        // checked: "yes"
3032        {
3033            let mut fragments = HashMap::new();
3034            fragments.insert(
3035                "index.html".to_string(),
3036                FragmentList {
3037                    fragments: vec![
3038                        WebUIFragment::raw("<input"),
3039                        WebUIFragment::attribute_boolean(
3040                            "checked",
3041                            ConditionExpr::identifier("checked"),
3042                        ),
3043                        WebUIFragment::raw(">"),
3044                    ],
3045                },
3046            );
3047            let protocol = WebUIProtocol::new(fragments);
3048            let state = test_json!({"checked": "yes"});
3049            let mut writer = TestWriter::new();
3050            handle(
3051                &protocol,
3052                &state,
3053                &RenderOptions::new("index.html", "/"),
3054                &mut writer,
3055            )
3056            .unwrap();
3057            assert_eq!(writer.get_content(), "<input checked>");
3058        }
3059        // checked: {} (empty object is truthy)
3060        {
3061            let mut fragments = HashMap::new();
3062            fragments.insert(
3063                "index.html".to_string(),
3064                FragmentList {
3065                    fragments: vec![
3066                        WebUIFragment::raw("<input"),
3067                        WebUIFragment::attribute_boolean(
3068                            "checked",
3069                            ConditionExpr::identifier("checked"),
3070                        ),
3071                        WebUIFragment::raw(">"),
3072                    ],
3073                },
3074            );
3075            let protocol = WebUIProtocol::new(fragments);
3076            let state = test_json!({"checked": {}});
3077            let mut writer = TestWriter::new();
3078            handle(
3079                &protocol,
3080                &state,
3081                &RenderOptions::new("index.html", "/"),
3082                &mut writer,
3083            )
3084            .unwrap();
3085            // Empty object is falsy in this expression evaluator
3086            assert_eq!(writer.get_content(), "<input>");
3087        }
3088        // checked: "false" (string "false" is truthy)
3089        {
3090            let mut fragments = HashMap::new();
3091            fragments.insert(
3092                "index.html".to_string(),
3093                FragmentList {
3094                    fragments: vec![
3095                        WebUIFragment::raw("<input"),
3096                        WebUIFragment::attribute_boolean(
3097                            "checked",
3098                            ConditionExpr::identifier("checked"),
3099                        ),
3100                        WebUIFragment::raw(">"),
3101                    ],
3102                },
3103            );
3104            let protocol = WebUIProtocol::new(fragments);
3105            let state = test_json!({"checked": "false"});
3106            let mut writer = TestWriter::new();
3107            handle(
3108                &protocol,
3109                &state,
3110                &RenderOptions::new("index.html", "/"),
3111                &mut writer,
3112            )
3113            .unwrap();
3114            assert_eq!(writer.get_content(), "<input checked>");
3115        }
3116    }
3117
3118    #[test]
3119    fn test_boolean_attr_falsy_values() {
3120        // checked: 0
3121        {
3122            let mut fragments = HashMap::new();
3123            fragments.insert(
3124                "index.html".to_string(),
3125                FragmentList {
3126                    fragments: vec![
3127                        WebUIFragment::raw("<input"),
3128                        WebUIFragment::attribute_boolean(
3129                            "checked",
3130                            ConditionExpr::identifier("checked"),
3131                        ),
3132                        WebUIFragment::raw(">"),
3133                    ],
3134                },
3135            );
3136            let protocol = WebUIProtocol::new(fragments);
3137            let state = test_json!({"checked": 0});
3138            let mut writer = TestWriter::new();
3139            handle(
3140                &protocol,
3141                &state,
3142                &RenderOptions::new("index.html", "/"),
3143                &mut writer,
3144            )
3145            .unwrap();
3146            assert_eq!(writer.get_content(), "<input>");
3147        }
3148        // checked: ""
3149        {
3150            let mut fragments = HashMap::new();
3151            fragments.insert(
3152                "index.html".to_string(),
3153                FragmentList {
3154                    fragments: vec![
3155                        WebUIFragment::raw("<input"),
3156                        WebUIFragment::attribute_boolean(
3157                            "checked",
3158                            ConditionExpr::identifier("checked"),
3159                        ),
3160                        WebUIFragment::raw(">"),
3161                    ],
3162                },
3163            );
3164            let protocol = WebUIProtocol::new(fragments);
3165            let state = test_json!({"checked": ""});
3166            let mut writer = TestWriter::new();
3167            handle(
3168                &protocol,
3169                &state,
3170                &RenderOptions::new("index.html", "/"),
3171                &mut writer,
3172            )
3173            .unwrap();
3174            assert_eq!(writer.get_content(), "<input>");
3175        }
3176        // checked: false
3177        {
3178            let mut fragments = HashMap::new();
3179            fragments.insert(
3180                "index.html".to_string(),
3181                FragmentList {
3182                    fragments: vec![
3183                        WebUIFragment::raw("<input"),
3184                        WebUIFragment::attribute_boolean(
3185                            "checked",
3186                            ConditionExpr::identifier("checked"),
3187                        ),
3188                        WebUIFragment::raw(">"),
3189                    ],
3190                },
3191            );
3192            let protocol = WebUIProtocol::new(fragments);
3193            let state = test_json!({"checked": false});
3194            let mut writer = TestWriter::new();
3195            handle(
3196                &protocol,
3197                &state,
3198                &RenderOptions::new("index.html", "/"),
3199                &mut writer,
3200            )
3201            .unwrap();
3202            assert_eq!(writer.get_content(), "<input>");
3203        }
3204        // no checked key at all
3205        {
3206            let mut fragments = HashMap::new();
3207            fragments.insert(
3208                "index.html".to_string(),
3209                FragmentList {
3210                    fragments: vec![
3211                        WebUIFragment::raw("<input"),
3212                        WebUIFragment::attribute_boolean(
3213                            "checked",
3214                            ConditionExpr::identifier("checked"),
3215                        ),
3216                        WebUIFragment::raw(">"),
3217                    ],
3218                },
3219            );
3220            let protocol = WebUIProtocol::new(fragments);
3221            let state = test_json!({});
3222            let mut writer = TestWriter::new();
3223            handle(
3224                &protocol,
3225                &state,
3226                &RenderOptions::new("index.html", "/"),
3227                &mut writer,
3228            )
3229            .unwrap();
3230            assert_eq!(writer.get_content(), "<input>");
3231        }
3232    }
3233
3234    #[test]
3235    fn test_boolean_attr_expression_true() {
3236        let mut fragments = HashMap::new();
3237        fragments.insert(
3238            "index.html".to_string(),
3239            FragmentList {
3240                fragments: vec![
3241                    WebUIFragment::raw("<button"),
3242                    WebUIFragment::attribute_boolean(
3243                        "disabled",
3244                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
3245                    ),
3246                    WebUIFragment::raw(">Click</button>"),
3247                ],
3248            },
3249        );
3250        let protocol = WebUIProtocol::new(fragments);
3251        let state = test_json!({"itemCount": 5});
3252        let mut writer = TestWriter::new();
3253        handle(
3254            &protocol,
3255            &state,
3256            &RenderOptions::new("index.html", "/"),
3257            &mut writer,
3258        )
3259        .unwrap();
3260        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
3261    }
3262
3263    #[test]
3264    fn test_boolean_attr_expression_false() {
3265        let mut fragments = HashMap::new();
3266        fragments.insert(
3267            "index.html".to_string(),
3268            FragmentList {
3269                fragments: vec![
3270                    WebUIFragment::raw("<button"),
3271                    WebUIFragment::attribute_boolean(
3272                        "disabled",
3273                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
3274                    ),
3275                    WebUIFragment::raw(">Click</button>"),
3276                ],
3277            },
3278        );
3279        let protocol = WebUIProtocol::new(fragments);
3280        let state = test_json!({"itemCount": 3});
3281        let mut writer = TestWriter::new();
3282        handle(
3283            &protocol,
3284            &state,
3285            &RenderOptions::new("index.html", "/"),
3286            &mut writer,
3287        )
3288        .unwrap();
3289        assert_eq!(writer.get_content(), "<button>Click</button>");
3290    }
3291
3292    // ── GROUP 6: Mixed Attributes ─────────────────────────────────────
3293
3294    #[test]
3295    fn test_nested_component_attr_capture() {
3296        let mut fragments = HashMap::new();
3297        fragments.insert(
3298            "index.html".to_string(),
3299            FragmentList {
3300                fragments: vec![
3301                    WebUIFragment::raw("<parent-component"),
3302                    WebUIFragment {
3303                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3304                            WebUIFragmentAttribute {
3305                                name: "title".into(),
3306                                template: "parent-title".into(),
3307                                attr_start: true,
3308                                ..Default::default()
3309                            },
3310                        )),
3311                    },
3312                    WebUIFragment::raw(">"),
3313                    WebUIFragment::component("parent-component"),
3314                    WebUIFragment::raw("</parent-component>"),
3315                ],
3316            },
3317        );
3318        fragments.insert(
3319            "parent-title".to_string(),
3320            FragmentList {
3321                fragments: vec![
3322                    WebUIFragment::raw("Hello "),
3323                    WebUIFragment::signal("who", false),
3324                ],
3325            },
3326        );
3327        fragments.insert(
3328            "parent-component".to_string(),
3329            FragmentList {
3330                fragments: vec![
3331                    WebUIFragment::raw("<child-component"),
3332                    WebUIFragment {
3333                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3334                            WebUIFragmentAttribute {
3335                                name: "title".into(),
3336                                template: "child-title".into(),
3337                                attr_start: true,
3338                                ..Default::default()
3339                            },
3340                        )),
3341                    },
3342                    WebUIFragment::raw(">"),
3343                    WebUIFragment::component("child-component"),
3344                    WebUIFragment::raw("</child-component>"),
3345                ],
3346            },
3347        );
3348        fragments.insert(
3349            "child-title".to_string(),
3350            FragmentList {
3351                fragments: vec![
3352                    WebUIFragment::raw("Child of "),
3353                    WebUIFragment::signal("title", false),
3354                ],
3355            },
3356        );
3357        fragments.insert(
3358            "child-component".to_string(),
3359            FragmentList {
3360                fragments: vec![
3361                    WebUIFragment::raw("<span>"),
3362                    WebUIFragment::signal("title", false),
3363                    WebUIFragment::raw("</span>"),
3364                ],
3365            },
3366        );
3367        let protocol = WebUIProtocol::new(fragments);
3368        let state = test_json!({"who": "<world>"});
3369        let mut writer = TestWriter::new();
3370        handle(
3371            &protocol,
3372            &state,
3373            &RenderOptions::new("index.html", "/"),
3374            &mut writer,
3375        )
3376        .unwrap();
3377        assert_eq!(
3378            writer.get_content(),
3379            "<parent-component title=\"Hello &lt;world&gt;\"><child-component title=\"Child of Hello &lt;world&gt;\"><span>Child of Hello &lt;world&gt;</span></child-component></parent-component>"
3380        );
3381    }
3382
3383    #[test]
3384    fn test_grandchild_attr_propagation() {
3385        let mut fragments = HashMap::new();
3386        fragments.insert(
3387            "index.html".to_string(),
3388            FragmentList {
3389                fragments: vec![
3390                    WebUIFragment::raw("<parent-component"),
3391                    WebUIFragment {
3392                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3393                            WebUIFragmentAttribute {
3394                                name: "title".into(),
3395                                template: "p-title".into(),
3396                                attr_start: true,
3397                                ..Default::default()
3398                            },
3399                        )),
3400                    },
3401                    WebUIFragment::raw(">"),
3402                    WebUIFragment::component("parent-component"),
3403                    WebUIFragment::raw("</parent-component>"),
3404                ],
3405            },
3406        );
3407        fragments.insert(
3408            "p-title".to_string(),
3409            FragmentList {
3410                fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
3411            },
3412        );
3413        fragments.insert(
3414            "parent-component".to_string(),
3415            FragmentList {
3416                fragments: vec![
3417                    WebUIFragment::raw("<child-component"),
3418                    WebUIFragment {
3419                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3420                            WebUIFragmentAttribute {
3421                                name: "title".into(),
3422                                template: "c-title".into(),
3423                                attr_start: true,
3424                                ..Default::default()
3425                            },
3426                        )),
3427                    },
3428                    WebUIFragment::raw(">"),
3429                    WebUIFragment::component("child-component"),
3430                    WebUIFragment::raw("</child-component>"),
3431                ],
3432            },
3433        );
3434        fragments.insert(
3435            "c-title".to_string(),
3436            FragmentList {
3437                fragments: vec![
3438                    WebUIFragment::raw("C("),
3439                    WebUIFragment::signal("title", false),
3440                    WebUIFragment::raw(")-"),
3441                    WebUIFragment::signal("cExtra", false),
3442                ],
3443            },
3444        );
3445        fragments.insert(
3446            "child-component".to_string(),
3447            FragmentList {
3448                fragments: vec![
3449                    WebUIFragment::raw("<grandchild-component"),
3450                    WebUIFragment {
3451                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3452                            WebUIFragmentAttribute {
3453                                name: "title".into(),
3454                                value: "title".into(),
3455                                attr_start: true,
3456                                ..Default::default()
3457                            },
3458                        )),
3459                    },
3460                    WebUIFragment::raw(">"),
3461                    WebUIFragment::component("grandchild-component"),
3462                    WebUIFragment::raw("</grandchild-component>"),
3463                ],
3464            },
3465        );
3466        fragments.insert(
3467            "grandchild-component".to_string(),
3468            FragmentList {
3469                fragments: vec![
3470                    WebUIFragment::raw("<span>"),
3471                    WebUIFragment::signal("title", false),
3472                    WebUIFragment::raw("</span>"),
3473                ],
3474            },
3475        );
3476        let protocol = WebUIProtocol::new(fragments);
3477        let state = test_json!({"p": "<p>", "cExtra": "x&y"});
3478        let mut writer = TestWriter::new();
3479        handle(
3480            &protocol,
3481            &state,
3482            &RenderOptions::new("index.html", "/"),
3483            &mut writer,
3484        )
3485        .unwrap();
3486        assert_eq!(
3487            writer.get_content(),
3488            "<parent-component title=\"P:&lt;p&gt;\"><child-component title=\"C(P:&lt;p&gt;)-x&amp;y\"><grandchild-component title=\"C(P:&lt;p&gt;)-x&amp;y\"><span>C(P:&lt;p&gt;)-x&amp;y</span></grandchild-component></child-component></parent-component>"
3489        );
3490    }
3491
3492    #[test]
3493    fn test_for_loop_component_attr() {
3494        let mut fragments = HashMap::new();
3495        fragments.insert(
3496            "index.html".to_string(),
3497            FragmentList {
3498                fragments: vec![
3499                    WebUIFragment::raw("<parent-component"),
3500                    WebUIFragment {
3501                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3502                            WebUIFragmentAttribute {
3503                                name: "title".into(),
3504                                template: "parent-title-loop".into(),
3505                                attr_start: true,
3506                                ..Default::default()
3507                            },
3508                        )),
3509                    },
3510                    WebUIFragment::raw(">"),
3511                    WebUIFragment::component("parent-component"),
3512                    WebUIFragment::raw("</parent-component>"),
3513                ],
3514            },
3515        );
3516        fragments.insert(
3517            "parent-title-loop".to_string(),
3518            FragmentList {
3519                fragments: vec![
3520                    WebUIFragment::raw("Parent:"),
3521                    WebUIFragment::signal("who", false),
3522                ],
3523            },
3524        );
3525        fragments.insert(
3526            "parent-component".to_string(),
3527            FragmentList {
3528                fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
3529            },
3530        );
3531        fragments.insert(
3532            "child-loop".to_string(),
3533            FragmentList {
3534                fragments: vec![
3535                    WebUIFragment::raw("<child-component"),
3536                    WebUIFragment {
3537                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3538                            WebUIFragmentAttribute {
3539                                name: "title".into(),
3540                                template: "child-title-loop".into(),
3541                                attr_start: true,
3542                                ..Default::default()
3543                            },
3544                        )),
3545                    },
3546                    WebUIFragment::raw(">"),
3547                    WebUIFragment::component("child-component"),
3548                    WebUIFragment::raw("</child-component>"),
3549                ],
3550            },
3551        );
3552        fragments.insert(
3553            "child-title-loop".to_string(),
3554            FragmentList {
3555                fragments: vec![
3556                    WebUIFragment::raw("Hi "),
3557                    WebUIFragment::signal("item.name", false),
3558                    WebUIFragment::raw(" / "),
3559                    WebUIFragment::signal("title", false),
3560                ],
3561            },
3562        );
3563        fragments.insert(
3564            "child-component".to_string(),
3565            FragmentList {
3566                fragments: vec![
3567                    WebUIFragment::raw("<span>"),
3568                    WebUIFragment::signal("title", false),
3569                    WebUIFragment::raw("</span>"),
3570                ],
3571            },
3572        );
3573        let protocol = WebUIProtocol::new(fragments);
3574        let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
3575        let mut writer = TestWriter::new();
3576        handle(
3577            &protocol,
3578            &state,
3579            &RenderOptions::new("index.html", "/"),
3580            &mut writer,
3581        )
3582        .unwrap();
3583        assert_eq!(
3584            writer.get_content(),
3585            "<parent-component title=\"Parent:Bob\"><child-component title=\"Hi A&lt;1&gt; &#x2F; Parent:Bob\"><span>Hi A&lt;1&gt; &#x2F; Parent:Bob</span></child-component><child-component title=\"Hi B&amp;2 &#x2F; Parent:Bob\"><span>Hi B&amp;2 &#x2F; Parent:Bob</span></child-component></parent-component>"
3586        );
3587    }
3588
3589    #[test]
3590    fn test_multiple_template_attrs() {
3591        let mut fragments = HashMap::new();
3592        fragments.insert(
3593            "index.html".to_string(),
3594            FragmentList {
3595                fragments: vec![
3596                    WebUIFragment::raw("<my-component"),
3597                    WebUIFragment {
3598                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3599                            WebUIFragmentAttribute {
3600                                name: "title".into(),
3601                                template: "attr-title".into(),
3602                                attr_start: true,
3603                                ..Default::default()
3604                            },
3605                        )),
3606                    },
3607                    WebUIFragment {
3608                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3609                            WebUIFragmentAttribute {
3610                                name: "data-title".into(),
3611                                template: "attr-data-title".into(),
3612                                attr_start: false,
3613                                ..Default::default()
3614                            },
3615                        )),
3616                    },
3617                    WebUIFragment {
3618                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3619                            WebUIFragmentAttribute {
3620                                name: "aria-label".into(),
3621                                template: "attr-aria-label".into(),
3622                                attr_start: false,
3623                                ..Default::default()
3624                            },
3625                        )),
3626                    },
3627                    WebUIFragment::raw(">"),
3628                    WebUIFragment::component("my-component"),
3629                    WebUIFragment::raw("</my-component>"),
3630                ],
3631            },
3632        );
3633        fragments.insert(
3634            "attr-title".to_string(),
3635            FragmentList {
3636                fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
3637            },
3638        );
3639        fragments.insert(
3640            "attr-data-title".to_string(),
3641            FragmentList {
3642                fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
3643            },
3644        );
3645        fragments.insert(
3646            "attr-aria-label".to_string(),
3647            FragmentList {
3648                fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
3649            },
3650        );
3651        fragments.insert(
3652            "my-component".to_string(),
3653            FragmentList {
3654                fragments: vec![
3655                    WebUIFragment::raw("<span>"),
3656                    WebUIFragment::signal("title", false),
3657                    WebUIFragment::raw("|"),
3658                    WebUIFragment::signal("dataTitle", false),
3659                    WebUIFragment::raw("|"),
3660                    WebUIFragment::signal("ariaLabel", false),
3661                    WebUIFragment::raw("</span>"),
3662                ],
3663            },
3664        );
3665        let protocol = WebUIProtocol::new(fragments);
3666        let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
3667        let mut writer = TestWriter::new();
3668        handle(
3669            &protocol,
3670            &state,
3671            &RenderOptions::new("index.html", "/"),
3672            &mut writer,
3673        )
3674        .unwrap();
3675        assert_eq!(
3676            writer.get_content(),
3677            "<my-component title=\"T:&lt;t&amp;1&gt;\" data-title=\"D:d&lt;2&gt;\" aria-label=\"A:a&amp;3\"><span>T:&lt;t&amp;1&gt;|D:d&lt;2&gt;|A:a&amp;3</span></my-component>"
3678        );
3679    }
3680
3681    #[test]
3682    fn test_attr_priority_over_global() {
3683        let mut fragments = HashMap::new();
3684        fragments.insert(
3685            "index.html".to_string(),
3686            FragmentList {
3687                fragments: vec![
3688                    WebUIFragment::raw("<my-component"),
3689                    WebUIFragment {
3690                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3691                            WebUIFragmentAttribute {
3692                                name: "title".into(),
3693                                value: "Attribute Title".into(),
3694                                raw_value: true,
3695                                attr_start: true,
3696                                ..Default::default()
3697                            },
3698                        )),
3699                    },
3700                    WebUIFragment::raw(">"),
3701                    WebUIFragment::component("my-component"),
3702                    WebUIFragment::raw("</my-component>"),
3703                ],
3704            },
3705        );
3706        fragments.insert(
3707            "my-component".to_string(),
3708            FragmentList {
3709                fragments: vec![
3710                    WebUIFragment::raw("<span>"),
3711                    WebUIFragment::signal("title", false),
3712                    WebUIFragment::raw("</span>"),
3713                ],
3714            },
3715        );
3716        let protocol = WebUIProtocol::new(fragments);
3717        let state = test_json!({"title": "Global Title"});
3718        let mut writer = TestWriter::new();
3719        handle(
3720            &protocol,
3721            &state,
3722            &RenderOptions::new("index.html", "/"),
3723            &mut writer,
3724        )
3725        .unwrap();
3726        assert_eq!(
3727            writer.get_content(),
3728            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
3729        );
3730    }
3731
3732    #[test]
3733    fn test_attr_priority_over_local_and_global() {
3734        let mut fragments = HashMap::new();
3735        fragments.insert(
3736            "index.html".to_string(),
3737            FragmentList {
3738                fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
3739            },
3740        );
3741        fragments.insert(
3742            "loop".to_string(),
3743            FragmentList {
3744                fragments: vec![
3745                    WebUIFragment::raw("<my-component"),
3746                    WebUIFragment {
3747                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3748                            WebUIFragmentAttribute {
3749                                name: "title".into(),
3750                                value: "Attribute Title".into(),
3751                                raw_value: true,
3752                                attr_start: true,
3753                                ..Default::default()
3754                            },
3755                        )),
3756                    },
3757                    WebUIFragment::raw(">"),
3758                    WebUIFragment::component("my-component"),
3759                    WebUIFragment::raw("</my-component>"),
3760                ],
3761            },
3762        );
3763        fragments.insert(
3764            "my-component".to_string(),
3765            FragmentList {
3766                fragments: vec![
3767                    WebUIFragment::raw("<span>"),
3768                    WebUIFragment::signal("title", false),
3769                    WebUIFragment::raw("</span>"),
3770                ],
3771            },
3772        );
3773        let protocol = WebUIProtocol::new(fragments);
3774        let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
3775        let mut writer = TestWriter::new();
3776        handle(
3777            &protocol,
3778            &state,
3779            &RenderOptions::new("index.html", "/"),
3780            &mut writer,
3781        )
3782        .unwrap();
3783        assert_eq!(
3784            writer.get_content(),
3785            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
3786        );
3787    }
3788
3789    #[test]
3790    fn test_boolean_attr_first_component_attr() {
3791        let mut fragments = HashMap::new();
3792        fragments.insert(
3793            "index.html".to_string(),
3794            FragmentList {
3795                fragments: vec![
3796                    WebUIFragment::raw("<my-component"),
3797                    WebUIFragment {
3798                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3799                            WebUIFragmentAttribute {
3800                                name: "disabled".into(),
3801                                attr_start: true,
3802                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
3803                                ..Default::default()
3804                            },
3805                        )),
3806                    },
3807                    WebUIFragment {
3808                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3809                            WebUIFragmentAttribute {
3810                                name: "label".into(),
3811                                value: "Component Label".into(),
3812                                raw_value: true,
3813                                attr_start: false,
3814                                ..Default::default()
3815                            },
3816                        )),
3817                    },
3818                    WebUIFragment::raw(">"),
3819                    WebUIFragment::component("my-component"),
3820                    WebUIFragment::raw("</my-component>"),
3821                ],
3822            },
3823        );
3824        fragments.insert(
3825            "my-component".to_string(),
3826            FragmentList {
3827                fragments: vec![
3828                    WebUIFragment::if_cond(
3829                        ConditionExpr::identifier("disabled"),
3830                        "disabledTemplate",
3831                    ),
3832                    WebUIFragment::raw("<span>"),
3833                    WebUIFragment::signal("label", false),
3834                    WebUIFragment::raw("</span>"),
3835                ],
3836            },
3837        );
3838        fragments.insert(
3839            "disabledTemplate".to_string(),
3840            FragmentList {
3841                fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
3842            },
3843        );
3844        let protocol = WebUIProtocol::new(fragments);
3845        let state = test_json!({"isDisabled": true});
3846        let mut writer = TestWriter::new();
3847        handle(
3848            &protocol,
3849            &state,
3850            &RenderOptions::new("index.html", "/"),
3851            &mut writer,
3852        )
3853        .unwrap();
3854        assert_eq!(
3855            writer.get_content(),
3856            "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
3857        );
3858    }
3859
3860    #[test]
3861    fn test_hyphenated_attr_camelcase() {
3862        let mut fragments = HashMap::new();
3863        fragments.insert(
3864            "index.html".to_string(),
3865            FragmentList {
3866                fragments: vec![
3867                    WebUIFragment::raw("<my-component"),
3868                    WebUIFragment {
3869                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3870                            WebUIFragmentAttribute {
3871                                name: "key-hyphen".into(),
3872                                value: "Local Value".into(),
3873                                raw_value: true,
3874                                attr_start: true,
3875                                ..Default::default()
3876                            },
3877                        )),
3878                    },
3879                    WebUIFragment::raw(">"),
3880                    WebUIFragment::component("my-component"),
3881                    WebUIFragment::raw("</my-component>"),
3882                ],
3883            },
3884        );
3885        fragments.insert(
3886            "my-component".to_string(),
3887            FragmentList {
3888                fragments: vec![
3889                    WebUIFragment::raw("<span>"),
3890                    WebUIFragment::signal("keyHyphen", false),
3891                    WebUIFragment::raw("</span>"),
3892                ],
3893            },
3894        );
3895        let protocol = WebUIProtocol::new(fragments);
3896        let state = test_json!({"keyHyphen": "Global Value"});
3897        let mut writer = TestWriter::new();
3898        handle(
3899            &protocol,
3900            &state,
3901            &RenderOptions::new("index.html", "/"),
3902            &mut writer,
3903        )
3904        .unwrap();
3905        assert_eq!(
3906            writer.get_content(),
3907            "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
3908        );
3909    }
3910
3911    #[test]
3912    fn test_skipped_component_attrs() {
3913        // Skipped attributes: class, style, role, data-*, aria-*
3914        // Plus framework-specific prefixes/names that the parser marks with attr_skip.
3915        // These render on the HTML element but are NOT passed into component attribute state.
3916        let mut fragments = HashMap::new();
3917        fragments.insert(
3918            "index.html".to_string(),
3919            FragmentList {
3920                fragments: vec![
3921                    WebUIFragment::raw("<test-component"),
3922                    // Skipped: class
3923                    WebUIFragment {
3924                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3925                            WebUIFragmentAttribute {
3926                                name: "class".into(),
3927                                value: "skippedClass".into(),
3928                                attr_start: true,
3929                                attr_skip: true,
3930                                ..Default::default()
3931                            },
3932                        )),
3933                    },
3934                    // Skipped: style
3935                    WebUIFragment {
3936                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3937                            WebUIFragmentAttribute {
3938                                name: "style".into(),
3939                                value: "skippedStyle".into(),
3940                                attr_skip: true,
3941                                ..Default::default()
3942                            },
3943                        )),
3944                    },
3945                    // Skipped: role
3946                    WebUIFragment {
3947                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3948                            WebUIFragmentAttribute {
3949                                name: "role".into(),
3950                                value: "skippedRole".into(),
3951                                attr_skip: true,
3952                                ..Default::default()
3953                            },
3954                        )),
3955                    },
3956                    // Skipped: data-testid (data-* prefix)
3957                    WebUIFragment {
3958                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3959                            WebUIFragmentAttribute {
3960                                name: "data-testid".into(),
3961                                value: "skippedDataTestid".into(),
3962                                attr_skip: true,
3963                                ..Default::default()
3964                            },
3965                        )),
3966                    },
3967                    // Skipped: aria-label (aria-* prefix)
3968                    WebUIFragment {
3969                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3970                            WebUIFragmentAttribute {
3971                                name: "aria-label".into(),
3972                                value: "skippedAriaLabel".into(),
3973                                attr_skip: true,
3974                                ..Default::default()
3975                            },
3976                        )),
3977                    },
3978                    // NOT skipped: title
3979                    WebUIFragment {
3980                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3981                            WebUIFragmentAttribute {
3982                                name: "title".into(),
3983                                value: "title".into(),
3984                                ..Default::default()
3985                            },
3986                        )),
3987                    },
3988                    WebUIFragment::raw(">"),
3989                    WebUIFragment::component("test-component"),
3990                    WebUIFragment::raw("</test-component>"),
3991                ],
3992            },
3993        );
3994        fragments.insert(
3995            "test-component".to_string(),
3996            FragmentList {
3997                fragments: vec![
3998                    WebUIFragment::raw("<span>"),
3999                    WebUIFragment::signal("title", false),
4000                    WebUIFragment::raw("-"),
4001                    WebUIFragment::signal("class", false),
4002                    WebUIFragment::raw("-"),
4003                    WebUIFragment::signal("style", false),
4004                    WebUIFragment::raw("-"),
4005                    WebUIFragment::signal("role", false),
4006                    WebUIFragment::raw("-"),
4007                    WebUIFragment::signal("dataTestid", false),
4008                    WebUIFragment::raw("-"),
4009                    WebUIFragment::signal("ariaLabel", false),
4010                    WebUIFragment::raw("</span>"),
4011                ],
4012            },
4013        );
4014        let protocol = WebUIProtocol::new(fragments);
4015        let state = test_json!({
4016            "title": "Hello",
4017            "skippedClass": "my-class",
4018            "skippedStyle": "color:red",
4019            "skippedRole": "button",
4020            "skippedDataTestid": "test-id",
4021            "skippedAriaLabel": "label-text"
4022        });
4023        let mut writer = TestWriter::new();
4024        handle(
4025            &protocol,
4026            &state,
4027            &RenderOptions::new("index.html", "/"),
4028            &mut writer,
4029        )
4030        .unwrap();
4031        // Skipped attrs render on the element but their values are NOT accessible inside the component.
4032        // The component's signals for skipped attrs resolve to empty strings.
4033        // Only "title" (non-skipped) is accessible.
4034        assert_eq!(
4035            writer.get_content(),
4036            "<test-component class=\"my-class\" style=\"color:red\" role=\"button\" data-testid=\"test-id\" aria-label=\"label-text\" title=\"Hello\"><span>Hello-----</span></test-component>"
4037        );
4038    }
4039
4040    // ── GROUP 7: Attribute Inheritance ─────────────────────────────────
4041
4042    #[test]
4043    fn test_attr_inherit_parent_to_child() {
4044        let mut fragments = HashMap::new();
4045        fragments.insert(
4046            "index.html".to_string(),
4047            FragmentList {
4048                fragments: vec![
4049                    WebUIFragment::raw("<parent-component"),
4050                    WebUIFragment {
4051                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4052                            WebUIFragmentAttribute {
4053                                name: "title".into(),
4054                                value: "Parent Title".into(),
4055                                raw_value: true,
4056                                attr_start: true,
4057                                ..Default::default()
4058                            },
4059                        )),
4060                    },
4061                    WebUIFragment::raw(">"),
4062                    WebUIFragment::component("parent-component"),
4063                    WebUIFragment::raw("</parent-component>"),
4064                ],
4065            },
4066        );
4067        fragments.insert(
4068            "parent-component".to_string(),
4069            FragmentList {
4070                fragments: vec![
4071                    WebUIFragment::raw("<h1>"),
4072                    WebUIFragment::signal("title", false),
4073                    WebUIFragment::raw("</h1><child-component"),
4074                    WebUIFragment {
4075                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4076                            WebUIFragmentAttribute {
4077                                name: "title".into(),
4078                                value: "title".into(),
4079                                attr_start: true,
4080                                ..Default::default()
4081                            },
4082                        )),
4083                    },
4084                    WebUIFragment::raw(">"),
4085                    WebUIFragment::component("child-component"),
4086                    WebUIFragment::raw("</child-component>"),
4087                ],
4088            },
4089        );
4090        fragments.insert(
4091            "child-component".to_string(),
4092            FragmentList {
4093                fragments: vec![
4094                    WebUIFragment::raw("<h2>"),
4095                    WebUIFragment::signal("title", false),
4096                    WebUIFragment::raw("</h2>"),
4097                ],
4098            },
4099        );
4100        let protocol = WebUIProtocol::new(fragments);
4101        let state = test_json!({});
4102        let mut writer = TestWriter::new();
4103        handle(
4104            &protocol,
4105            &state,
4106            &RenderOptions::new("index.html", "/"),
4107            &mut writer,
4108        )
4109        .unwrap();
4110        assert_eq!(
4111            writer.get_content(),
4112            "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
4113        );
4114    }
4115
4116    #[test]
4117    fn test_attr_inherit_deep() {
4118        let mut fragments = HashMap::new();
4119        fragments.insert(
4120            "index.html".to_string(),
4121            FragmentList {
4122                fragments: vec![
4123                    WebUIFragment::raw("<parent-component"),
4124                    WebUIFragment {
4125                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4126                            WebUIFragmentAttribute {
4127                                name: "title".into(),
4128                                value: "Parent Title".into(),
4129                                raw_value: true,
4130                                attr_start: true,
4131                                ..Default::default()
4132                            },
4133                        )),
4134                    },
4135                    WebUIFragment::raw(">"),
4136                    WebUIFragment::component("parent-component"),
4137                    WebUIFragment::raw("</parent-component>"),
4138                ],
4139            },
4140        );
4141        fragments.insert(
4142            "parent-component".to_string(),
4143            FragmentList {
4144                fragments: vec![
4145                    WebUIFragment::raw("<child-component"),
4146                    WebUIFragment {
4147                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4148                            WebUIFragmentAttribute {
4149                                name: "title".into(),
4150                                value: "Child Title".into(),
4151                                raw_value: true,
4152                                attr_start: true,
4153                                ..Default::default()
4154                            },
4155                        )),
4156                    },
4157                    WebUIFragment::raw(">"),
4158                    WebUIFragment::component("child-component"),
4159                    WebUIFragment::raw("</child-component>"),
4160                ],
4161            },
4162        );
4163        fragments.insert(
4164            "child-component".to_string(),
4165            FragmentList {
4166                fragments: vec![
4167                    WebUIFragment::raw("<grandchild-component"),
4168                    WebUIFragment {
4169                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4170                            WebUIFragmentAttribute {
4171                                name: "title".into(),
4172                                value: "title".into(),
4173                                attr_start: true,
4174                                ..Default::default()
4175                            },
4176                        )),
4177                    },
4178                    WebUIFragment::raw(">"),
4179                    WebUIFragment::component("grandchild-component"),
4180                    WebUIFragment::raw("</grandchild-component>"),
4181                ],
4182            },
4183        );
4184        fragments.insert(
4185            "grandchild-component".to_string(),
4186            FragmentList {
4187                fragments: vec![
4188                    WebUIFragment::raw("<h3>"),
4189                    WebUIFragment::signal("title", false),
4190                    WebUIFragment::raw("</h3>"),
4191                ],
4192            },
4193        );
4194        let protocol = WebUIProtocol::new(fragments);
4195        let state = test_json!({});
4196        let mut writer = TestWriter::new();
4197        handle(
4198            &protocol,
4199            &state,
4200            &RenderOptions::new("index.html", "/"),
4201            &mut writer,
4202        )
4203        .unwrap();
4204        assert_eq!(
4205            writer.get_content(),
4206            "<parent-component title=\"Parent Title\"><child-component title=\"Child Title\"><grandchild-component title=\"Child Title\"><h3>Child Title</h3></grandchild-component></child-component></parent-component>"
4207        );
4208    }
4209
4210    #[test]
4211    fn test_complex_attr_access() {
4212        let mut fragments = HashMap::new();
4213        fragments.insert(
4214            "index.html".to_string(),
4215            FragmentList {
4216                fragments: vec![
4217                    WebUIFragment::raw("<my-component"),
4218                    WebUIFragment {
4219                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4220                            WebUIFragmentAttribute {
4221                                name: ":item".into(),
4222                                value: "complexItem".into(),
4223                                attr_start: true,
4224                                complex: true,
4225                                ..Default::default()
4226                            },
4227                        )),
4228                    },
4229                    WebUIFragment::raw(">"),
4230                    WebUIFragment::component("my-component"),
4231                    WebUIFragment::raw("</my-component>"),
4232                ],
4233            },
4234        );
4235        fragments.insert(
4236            "my-component".to_string(),
4237            FragmentList {
4238                fragments: vec![
4239                    WebUIFragment::raw("<span>"),
4240                    WebUIFragment::signal("item.foo", false),
4241                    WebUIFragment::raw("</span><p>"),
4242                    WebUIFragment::signal("item.bar", false),
4243                    WebUIFragment::raw("</p>"),
4244                ],
4245            },
4246        );
4247        let protocol = WebUIProtocol::new(fragments);
4248        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
4249        let mut writer = TestWriter::new();
4250        handle(
4251            &protocol,
4252            &state,
4253            &RenderOptions::new("index.html", "/"),
4254            &mut writer,
4255        )
4256        .unwrap();
4257        assert_eq!(
4258            writer.get_content(),
4259            "<my-component><span>1</span><p>true</p></my-component>"
4260        );
4261    }
4262
4263    #[test]
4264    fn test_complex_attr_for_loop() {
4265        let mut fragments = HashMap::new();
4266        fragments.insert(
4267            "index.html".to_string(),
4268            FragmentList {
4269                fragments: vec![WebUIFragment::for_loop(
4270                    "item",
4271                    "list.items",
4272                    "listTemplate",
4273                )],
4274            },
4275        );
4276        fragments.insert(
4277            "listTemplate".to_string(),
4278            FragmentList {
4279                fragments: vec![
4280                    WebUIFragment {
4281                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4282                            WebUIFragmentAttribute {
4283                                name: ":item".into(),
4284                                value: "item".into(),
4285                                attr_start: true,
4286                                complex: true,
4287                                ..Default::default()
4288                            },
4289                        )),
4290                    },
4291                    WebUIFragment::component("item_component"),
4292                ],
4293            },
4294        );
4295        fragments.insert(
4296            "item_component".to_string(),
4297            FragmentList {
4298                fragments: vec![
4299                    WebUIFragment::raw("<span>"),
4300                    WebUIFragment::signal("item.name", false),
4301                    WebUIFragment::raw("</span>"),
4302                ],
4303            },
4304        );
4305        let protocol = WebUIProtocol::new(fragments);
4306        let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
4307        let mut writer = TestWriter::new();
4308        handle(
4309            &protocol,
4310            &state,
4311            &RenderOptions::new("index.html", "/"),
4312            &mut writer,
4313        )
4314        .unwrap();
4315        assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
4316    }
4317
4318    #[test]
4319    fn test_complex_attr_nested_for() {
4320        let mut fragments = HashMap::new();
4321        fragments.insert(
4322            "index.html".to_string(),
4323            FragmentList {
4324                fragments: vec![WebUIFragment::for_loop(
4325                    "outer",
4326                    "data.outer",
4327                    "outerTemplate",
4328                )],
4329            },
4330        );
4331        fragments.insert(
4332            "outerTemplate".to_string(),
4333            FragmentList {
4334                fragments: vec![WebUIFragment::for_loop(
4335                    "middle",
4336                    "outer.middle",
4337                    "middleTemplate",
4338                )],
4339            },
4340        );
4341        fragments.insert(
4342            "middleTemplate".to_string(),
4343            FragmentList {
4344                fragments: vec![WebUIFragment::for_loop(
4345                    "inner",
4346                    "middle.inner",
4347                    "innerTemplate",
4348                )],
4349            },
4350        );
4351        fragments.insert(
4352            "innerTemplate".to_string(),
4353            FragmentList {
4354                fragments: vec![
4355                    WebUIFragment::raw("<card"),
4356                    WebUIFragment {
4357                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4358                            WebUIFragmentAttribute {
4359                                name: ":outer".into(),
4360                                value: "outer".into(),
4361                                attr_start: true,
4362                                complex: true,
4363                                ..Default::default()
4364                            },
4365                        )),
4366                    },
4367                    WebUIFragment {
4368                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4369                            WebUIFragmentAttribute {
4370                                name: ":middle".into(),
4371                                value: "middle".into(),
4372                                attr_start: false,
4373                                complex: true,
4374                                ..Default::default()
4375                            },
4376                        )),
4377                    },
4378                    WebUIFragment {
4379                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4380                            WebUIFragmentAttribute {
4381                                name: ":inner".into(),
4382                                value: "inner".into(),
4383                                attr_start: false,
4384                                complex: true,
4385                                ..Default::default()
4386                            },
4387                        )),
4388                    },
4389                    WebUIFragment::raw(">"),
4390                    WebUIFragment::component("card_component"),
4391                    WebUIFragment::raw("</card>"),
4392                ],
4393            },
4394        );
4395        fragments.insert(
4396            "card_component".to_string(),
4397            FragmentList {
4398                fragments: vec![
4399                    WebUIFragment::raw("<p>"),
4400                    WebUIFragment::signal("outer.label", false),
4401                    WebUIFragment::raw(" / "),
4402                    WebUIFragment::signal("middle.label", false),
4403                    WebUIFragment::raw(" / "),
4404                    WebUIFragment::signal("inner.label", false),
4405                    WebUIFragment::raw("</p>"),
4406                ],
4407            },
4408        );
4409        let protocol = WebUIProtocol::new(fragments);
4410        let state = test_json!({"data": {"outer": [
4411            {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
4412            {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
4413        ]}});
4414        let mut writer = TestWriter::new();
4415        handle(
4416            &protocol,
4417            &state,
4418            &RenderOptions::new("index.html", "/"),
4419            &mut writer,
4420        )
4421        .unwrap();
4422        assert_eq!(
4423            writer.get_content(),
4424            "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
4425        );
4426    }
4427
4428    // ── GROUP 8: Boolean Component State ──────────────────────────────
4429
4430    #[test]
4431    fn test_bool_component_state_true() {
4432        let mut fragments = HashMap::new();
4433        fragments.insert(
4434            "index.html".to_string(),
4435            FragmentList {
4436                fragments: vec![
4437                    WebUIFragment::raw("<my-component"),
4438                    WebUIFragment {
4439                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4440                            WebUIFragmentAttribute {
4441                                name: "disabled".into(),
4442                                attr_start: true,
4443                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4444                                ..Default::default()
4445                            },
4446                        )),
4447                    },
4448                    WebUIFragment::raw(">"),
4449                    WebUIFragment::component("my-component"),
4450                    WebUIFragment::raw("</my-component>"),
4451                ],
4452            },
4453        );
4454        fragments.insert(
4455            "my-component".to_string(),
4456            FragmentList {
4457                fragments: vec![
4458                    WebUIFragment::if_cond(
4459                        ConditionExpr::identifier("disabled"),
4460                        "disabledTemplate",
4461                    ),
4462                    WebUIFragment::if_cond(
4463                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
4464                        "enabledTemplate",
4465                    ),
4466                ],
4467            },
4468        );
4469        fragments.insert(
4470            "disabledTemplate".to_string(),
4471            FragmentList {
4472                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
4473            },
4474        );
4475        fragments.insert(
4476            "enabledTemplate".to_string(),
4477            FragmentList {
4478                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
4479            },
4480        );
4481        let protocol = WebUIProtocol::new(fragments);
4482        let state = test_json!({"isDisabled": true});
4483        let mut writer = TestWriter::new();
4484        handle(
4485            &protocol,
4486            &state,
4487            &RenderOptions::new("index.html", "/"),
4488            &mut writer,
4489        )
4490        .unwrap();
4491        assert_eq!(
4492            writer.get_content(),
4493            "<my-component disabled><span>Disabled</span></my-component>"
4494        );
4495    }
4496
4497    #[test]
4498    fn test_bool_component_state_false() {
4499        let mut fragments = HashMap::new();
4500        fragments.insert(
4501            "index.html".to_string(),
4502            FragmentList {
4503                fragments: vec![
4504                    WebUIFragment::raw("<my-component"),
4505                    WebUIFragment {
4506                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4507                            WebUIFragmentAttribute {
4508                                name: "disabled".into(),
4509                                attr_start: true,
4510                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4511                                ..Default::default()
4512                            },
4513                        )),
4514                    },
4515                    WebUIFragment::raw(">"),
4516                    WebUIFragment::component("my-component"),
4517                    WebUIFragment::raw("</my-component>"),
4518                ],
4519            },
4520        );
4521        fragments.insert(
4522            "my-component".to_string(),
4523            FragmentList {
4524                fragments: vec![
4525                    WebUIFragment::if_cond(
4526                        ConditionExpr::identifier("disabled"),
4527                        "disabledTemplate",
4528                    ),
4529                    WebUIFragment::if_cond(
4530                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
4531                        "enabledTemplate",
4532                    ),
4533                ],
4534            },
4535        );
4536        fragments.insert(
4537            "disabledTemplate".to_string(),
4538            FragmentList {
4539                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
4540            },
4541        );
4542        fragments.insert(
4543            "enabledTemplate".to_string(),
4544            FragmentList {
4545                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
4546            },
4547        );
4548        let protocol = WebUIProtocol::new(fragments);
4549        let state = test_json!({"isDisabled": false});
4550        let mut writer = TestWriter::new();
4551        handle(
4552            &protocol,
4553            &state,
4554            &RenderOptions::new("index.html", "/"),
4555            &mut writer,
4556        )
4557        .unwrap();
4558        assert_eq!(
4559            writer.get_content(),
4560            "<my-component><span>Enabled</span></my-component>"
4561        );
4562    }
4563
4564    #[test]
4565    fn test_bool_component_state_forward() {
4566        let mut fragments = HashMap::new();
4567        fragments.insert(
4568            "index.html".to_string(),
4569            FragmentList {
4570                fragments: vec![
4571                    WebUIFragment::raw("<parent-component"),
4572                    WebUIFragment {
4573                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4574                            WebUIFragmentAttribute {
4575                                name: "disabled".into(),
4576                                attr_start: true,
4577                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4578                                ..Default::default()
4579                            },
4580                        )),
4581                    },
4582                    WebUIFragment::raw(">"),
4583                    WebUIFragment::component("parent-component"),
4584                    WebUIFragment::raw("</parent-component>"),
4585                ],
4586            },
4587        );
4588        fragments.insert(
4589            "parent-component".to_string(),
4590            FragmentList {
4591                fragments: vec![
4592                    WebUIFragment::if_cond(
4593                        ConditionExpr::identifier("disabled"),
4594                        "parentDisabledTemplate",
4595                    ),
4596                    WebUIFragment::raw("<child-component"),
4597                    WebUIFragment {
4598                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4599                            WebUIFragmentAttribute {
4600                                name: "disabled".into(),
4601                                attr_start: true,
4602                                condition_tree: Some(ConditionExpr::identifier("disabled")),
4603                                ..Default::default()
4604                            },
4605                        )),
4606                    },
4607                    WebUIFragment::raw(">"),
4608                    WebUIFragment::component("child-component"),
4609                    WebUIFragment::raw("</child-component>"),
4610                ],
4611            },
4612        );
4613        fragments.insert(
4614            "parentDisabledTemplate".to_string(),
4615            FragmentList {
4616                fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
4617            },
4618        );
4619        fragments.insert(
4620            "child-component".to_string(),
4621            FragmentList {
4622                fragments: vec![
4623                    WebUIFragment::if_cond(
4624                        ConditionExpr::identifier("disabled"),
4625                        "childDisabledTemplate",
4626                    ),
4627                    WebUIFragment::if_cond(
4628                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
4629                        "childEnabledTemplate",
4630                    ),
4631                ],
4632            },
4633        );
4634        fragments.insert(
4635            "childDisabledTemplate".to_string(),
4636            FragmentList {
4637                fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
4638            },
4639        );
4640        fragments.insert(
4641            "childEnabledTemplate".to_string(),
4642            FragmentList {
4643                fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
4644            },
4645        );
4646
4647        // Test case 1: isDisabled = true
4648        {
4649            let protocol = WebUIProtocol::new(fragments.clone());
4650            let state = test_json!({"isDisabled": true});
4651            let mut writer = TestWriter::new();
4652            handle(
4653                &protocol,
4654                &state,
4655                &RenderOptions::new("index.html", "/"),
4656                &mut writer,
4657            )
4658            .unwrap();
4659            assert_eq!(
4660                writer.get_content(),
4661                "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
4662            );
4663        }
4664
4665        // Test case 2: isDisabled = false
4666        {
4667            let protocol = WebUIProtocol::new(fragments.clone());
4668            let state = test_json!({"isDisabled": false});
4669            let mut writer = TestWriter::new();
4670            handle(
4671                &protocol,
4672                &state,
4673                &RenderOptions::new("index.html", "/"),
4674                &mut writer,
4675            )
4676            .unwrap();
4677            assert_eq!(
4678                writer.get_content(),
4679                "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
4680            );
4681        }
4682    }
4683
4684    // ── GROUP 9: Hydration (SKIP) ─────────────────────────────────────
4685
4686    // TODO: test_hydration - requires FAST handler plugin integration; see plugin/fast.rs
4687
4688    // ── Component tests ──────────────────────────────────────────────
4689
4690    #[test]
4691    fn test_component_with_template() {
4692        let mut fragments = HashMap::new();
4693        fragments.insert(
4694            "index.html".to_string(),
4695            FragmentList {
4696                fragments: vec![
4697                    WebUIFragment::raw("<custom-element>"),
4698                    WebUIFragment::component("custom-element"),
4699                    WebUIFragment::raw("</custom-element>"),
4700                ],
4701            },
4702        );
4703        fragments.insert(
4704            "custom-element".to_string(),
4705            FragmentList {
4706                fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
4707            },
4708        );
4709        let protocol = WebUIProtocol::new(fragments);
4710        let state = test_json!({});
4711        let mut writer = TestWriter::new();
4712        handle(
4713            &protocol,
4714            &state,
4715            &RenderOptions::new("index.html", "/"),
4716            &mut writer,
4717        )
4718        .unwrap();
4719        assert_eq!(
4720            writer.get_content(),
4721            "<custom-element><div>Custom Element</div></custom-element>"
4722        );
4723        assert!(writer.is_ended());
4724    }
4725
4726    #[test]
4727    fn test_component_with_slots() {
4728        let mut fragments = HashMap::new();
4729        fragments.insert(
4730            "index.html".to_string(),
4731            FragmentList {
4732                fragments: vec![
4733                    WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
4734                    WebUIFragment::component("custom-element"),
4735                    WebUIFragment::raw("Hello World</custom-element>"),
4736                ],
4737            },
4738        );
4739        fragments.insert(
4740            "custom-element".to_string(),
4741            FragmentList {
4742                fragments: vec![WebUIFragment::raw("<slot></slot>")],
4743            },
4744        );
4745        let protocol = WebUIProtocol::new(fragments);
4746        let state = test_json!({});
4747        let mut writer = TestWriter::new();
4748        handle(
4749            &protocol,
4750            &state,
4751            &RenderOptions::new("index.html", "/"),
4752            &mut writer,
4753        )
4754        .unwrap();
4755        assert_eq!(
4756            writer.get_content(),
4757            "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
4758        );
4759        assert!(writer.is_ended());
4760    }
4761
4762    #[test]
4763    fn test_multiple_nested_components() {
4764        let mut fragments = HashMap::new();
4765        fragments.insert(
4766            "index.html".to_string(),
4767            FragmentList {
4768                fragments: vec![
4769                    WebUIFragment::raw("<div>"),
4770                    WebUIFragment::for_loop("item", "items", "templateRepeat"),
4771                    WebUIFragment::raw("</div>"),
4772                ],
4773            },
4774        );
4775        fragments.insert(
4776            "custom-button".to_string(),
4777            FragmentList {
4778                fragments: vec![WebUIFragment::raw("<slot></slot>")],
4779            },
4780        );
4781        fragments.insert(
4782            "custom-element".to_string(),
4783            FragmentList {
4784                fragments: vec![
4785                    WebUIFragment::raw("<custom-child>"),
4786                    WebUIFragment::component("custom-child"),
4787                    WebUIFragment::raw("</custom-child><slot></slot>"),
4788                ],
4789            },
4790        );
4791        fragments.insert(
4792            "custom-child".to_string(),
4793            FragmentList {
4794                fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
4795            },
4796        );
4797        fragments.insert(
4798            "templateRepeat".to_string(),
4799            FragmentList {
4800                fragments: vec![
4801                    WebUIFragment::raw("<custom-element>"),
4802                    WebUIFragment::component("custom-element"),
4803                    WebUIFragment::raw("<custom-button>"),
4804                    WebUIFragment::component("custom-button"),
4805                    WebUIFragment::raw("Ok</custom-button></custom-element>"),
4806                ],
4807            },
4808        );
4809        let protocol = WebUIProtocol::new(fragments);
4810        let state = test_json!({"items": [{"name": "Item1"}]});
4811        let mut writer = TestWriter::new();
4812        handle(
4813            &protocol,
4814            &state,
4815            &RenderOptions::new("index.html", "/"),
4816            &mut writer,
4817        )
4818        .unwrap();
4819        assert_eq!(
4820            writer.get_content(),
4821            "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
4822        );
4823        assert!(writer.is_ended());
4824    }
4825
4826    // ── Conditional tests ────────────────────────────────────────────
4827
4828    #[test]
4829    fn test_if_with_binary_expression() {
4830        let mut fragments = HashMap::new();
4831        fragments.insert(
4832            "index.html".to_string(),
4833            FragmentList {
4834                fragments: vec![
4835                    WebUIFragment::raw("<div>"),
4836                    WebUIFragment::if_cond(
4837                        ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
4838                        "if-1",
4839                    ),
4840                    WebUIFragment::raw("</div>"),
4841                ],
4842            },
4843        );
4844        fragments.insert(
4845            "if-1".to_string(),
4846            FragmentList {
4847                fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
4848            },
4849        );
4850        let protocol = WebUIProtocol::new(fragments);
4851
4852        // True case: x = 10 > 5
4853        let state_true = test_json!({"x": 10});
4854        let mut writer_true = TestWriter::new();
4855        handle(
4856            &protocol,
4857            &state_true,
4858            &RenderOptions::new("index.html", "/"),
4859            &mut writer_true,
4860        )
4861        .unwrap();
4862        assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
4863
4864        // False case: x = 1 <= 5
4865        let state_false = test_json!({"x": 1});
4866        let mut writer_false = TestWriter::new();
4867        handle(
4868            &protocol,
4869            &state_false,
4870            &RenderOptions::new("index.html", "/"),
4871            &mut writer_false,
4872        )
4873        .unwrap();
4874        assert_eq!(writer_false.get_content(), "<div></div>");
4875    }
4876
4877    #[test]
4878    fn test_for_if_overlapping_local_state() {
4879        let mut fragments = HashMap::new();
4880        fragments.insert(
4881            "index.html".to_string(),
4882            FragmentList {
4883                fragments: vec![
4884                    WebUIFragment::raw("<div>"),
4885                    WebUIFragment::for_loop("item", "items", "template1"),
4886                    WebUIFragment::raw("</div>"),
4887                ],
4888            },
4889        );
4890        fragments.insert(
4891            "template1".to_string(),
4892            FragmentList {
4893                fragments: vec![
4894                    WebUIFragment::raw("<div>"),
4895                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
4896                    WebUIFragment::raw("</div>"),
4897                ],
4898            },
4899        );
4900        fragments.insert(
4901            "ifBlock".to_string(),
4902            FragmentList {
4903                fragments: vec![
4904                    WebUIFragment::raw("<span>"),
4905                    WebUIFragment::signal("item.label", false),
4906                    WebUIFragment::raw("</span>"),
4907                ],
4908            },
4909        );
4910        let protocol = WebUIProtocol::new(fragments);
4911        let state = test_json!({
4912            "flag": false,
4913            "items": [
4914                {"label": "A", "flag": true},
4915                {"label": "B", "flag": false},
4916                {"label": "C", "flag": true}
4917            ]
4918        });
4919        let mut writer = TestWriter::new();
4920        handle(
4921            &protocol,
4922            &state,
4923            &RenderOptions::new("index.html", "/"),
4924            &mut writer,
4925        )
4926        .unwrap();
4927        assert_eq!(
4928            writer.get_content(),
4929            "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
4930        );
4931    }
4932
4933    #[test]
4934    fn test_for_if_global_flag_no_effect() {
4935        let mut fragments = HashMap::new();
4936        fragments.insert(
4937            "index.html".to_string(),
4938            FragmentList {
4939                fragments: vec![
4940                    WebUIFragment::raw("<div>"),
4941                    WebUIFragment::for_loop("item", "items", "template1"),
4942                    WebUIFragment::raw("</div>"),
4943                ],
4944            },
4945        );
4946        fragments.insert(
4947            "template1".to_string(),
4948            FragmentList {
4949                fragments: vec![
4950                    WebUIFragment::raw("<div>"),
4951                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
4952                    WebUIFragment::raw("</div>"),
4953                ],
4954            },
4955        );
4956        fragments.insert(
4957            "ifBlock".to_string(),
4958            FragmentList {
4959                fragments: vec![
4960                    WebUIFragment::raw("<span>"),
4961                    WebUIFragment::signal("item.label", false),
4962                    WebUIFragment::raw("</span>"),
4963                ],
4964            },
4965        );
4966        let protocol = WebUIProtocol::new(fragments);
4967        let state = test_json!({
4968            "item": {"flag": true},
4969            "items": [
4970                {"label": "A", "flag": false},
4971                {"label": "B", "flag": true},
4972                {"label": "C", "flag": false}
4973            ]
4974        });
4975        let mut writer = TestWriter::new();
4976        handle(
4977            &protocol,
4978            &state,
4979            &RenderOptions::new("index.html", "/"),
4980            &mut writer,
4981        )
4982        .unwrap();
4983        assert_eq!(
4984            writer.get_content(),
4985            "<div><div></div><div><span>B</span></div><div></div></div>"
4986        );
4987    }
4988
4989    // ── Recursive template test ──────────────────────────────────────
4990
4991    #[test]
4992    fn test_recursive_template_refs() {
4993        let mut fragments = HashMap::new();
4994        fragments.insert(
4995            "index.html".to_string(),
4996            FragmentList {
4997                fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
4998            },
4999        );
5000        fragments.insert(
5001            "static".to_string(),
5002            FragmentList {
5003                fragments: vec![
5004                    WebUIFragment::raw("<div expanded=\""),
5005                    WebUIFragment::signal("item.expanded", false),
5006                    WebUIFragment::raw("\" class=\""),
5007                    WebUIFragment::signal("testScenario", false),
5008                    WebUIFragment::raw("\"><span>"),
5009                    WebUIFragment::signal("item.name", false),
5010                    WebUIFragment::raw("</span>"),
5011                    WebUIFragment::for_loop("item", "item.children", "static"),
5012                    WebUIFragment::raw("</div>"),
5013                ],
5014            },
5015        );
5016        let protocol = WebUIProtocol::new(fragments);
5017        let state = test_json!({
5018            "testScenario": "RecursiveTemplatesWithGlobalState",
5019            "items": [
5020                {"name": "A", "expanded": "false", "children": []},
5021                {"name": "B", "expanded": "true", "children": [
5022                    {"name": "C", "expanded": "false"},
5023                    {"name": "D", "expanded": "false"}
5024                ]},
5025                {"name": "E", "expanded": "false"}
5026            ]
5027        });
5028        let mut writer = TestWriter::new();
5029        handle(
5030            &protocol,
5031            &state,
5032            &RenderOptions::new("index.html", "/"),
5033            &mut writer,
5034        )
5035        .unwrap();
5036        assert_eq!(
5037            writer.get_content(),
5038            "<div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>A</span></div><div expanded=\"true\" class=\"RecursiveTemplatesWithGlobalState\"><span>B</span><div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>C</span></div><div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>D</span></div></div><div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>E</span></div>"
5039        );
5040    }
5041
5042    // ── Advanced state management tests ──────────────────────────────
5043
5044    #[test]
5045    fn test_component_in_for_no_local_access() {
5046        let mut fragments = HashMap::new();
5047        fragments.insert(
5048            "index.html".to_string(),
5049            FragmentList {
5050                fragments: vec![
5051                    WebUIFragment::raw("<div>"),
5052                    WebUIFragment::for_loop("item", "items", "templateComponent"),
5053                    WebUIFragment::raw("</div>"),
5054                ],
5055            },
5056        );
5057        fragments.insert(
5058            "templateComponent".to_string(),
5059            FragmentList {
5060                fragments: vec![
5061                    WebUIFragment::raw("<component-tag>"),
5062                    WebUIFragment::component("my-component"),
5063                    WebUIFragment::raw("</component-tag>"),
5064                ],
5065            },
5066        );
5067        fragments.insert(
5068            "my-component".to_string(),
5069            FragmentList {
5070                fragments: vec![
5071                    WebUIFragment::raw("<span>"),
5072                    WebUIFragment::signal("name", false),
5073                    WebUIFragment::raw("</span>"),
5074                ],
5075            },
5076        );
5077        let protocol = WebUIProtocol::new(fragments);
5078        let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
5079        let mut writer = TestWriter::new();
5080        handle(
5081            &protocol,
5082            &state,
5083            &RenderOptions::new("index.html", "/"),
5084            &mut writer,
5085        )
5086        .unwrap();
5087        assert_eq!(
5088            writer.get_content(),
5089            "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
5090        );
5091    }
5092
5093    #[test]
5094    fn test_nested_for_hierarchical_state() {
5095        let mut fragments = HashMap::new();
5096        fragments.insert(
5097            "index.html".to_string(),
5098            FragmentList {
5099                fragments: vec![
5100                    WebUIFragment::raw("<div>"),
5101                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5102                    WebUIFragment::raw("</div>"),
5103                ],
5104            },
5105        );
5106        fragments.insert(
5107            "outerTemplate".to_string(),
5108            FragmentList {
5109                fragments: vec![
5110                    WebUIFragment::raw("<section>"),
5111                    WebUIFragment::signal("globalPrefix", false),
5112                    WebUIFragment::signal("outerItem.outerLabel", false),
5113                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5114                    WebUIFragment::raw("</section>"),
5115                ],
5116            },
5117        );
5118        fragments.insert(
5119            "innerTemplate".to_string(),
5120            FragmentList {
5121                fragments: vec![
5122                    WebUIFragment::raw("<p>"),
5123                    WebUIFragment::signal("globalPrefix", false),
5124                    WebUIFragment::signal("outerItem.outerLabel", false),
5125                    WebUIFragment::raw(": "),
5126                    WebUIFragment::signal("innerItem.innerLabel", false),
5127                    WebUIFragment::raw("</p>"),
5128                ],
5129            },
5130        );
5131        let protocol = WebUIProtocol::new(fragments);
5132        let state = test_json!({
5133            "globalPrefix": "Prefix: ",
5134            "outerItems": [
5135                {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
5136                {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
5137            ]
5138        });
5139        let mut writer = TestWriter::new();
5140        handle(
5141            &protocol,
5142            &state,
5143            &RenderOptions::new("index.html", "/"),
5144            &mut writer,
5145        )
5146        .unwrap();
5147        assert_eq!(
5148            writer.get_content(),
5149            "<div><section>Prefix: O1<p>Prefix: O1: I1</p><p>Prefix: O1: I2</p></section><section>Prefix: O2<p>Prefix: O2: I3</p></section></div>"
5150        );
5151    }
5152
5153    #[test]
5154    fn test_component_in_for_global_only() {
5155        let mut fragments = HashMap::new();
5156        fragments.insert(
5157            "index.html".to_string(),
5158            FragmentList {
5159                fragments: vec![
5160                    WebUIFragment::raw("<div>"),
5161                    WebUIFragment::for_loop("item", "items", "templateComponent"),
5162                    WebUIFragment::raw("</div>"),
5163                ],
5164            },
5165        );
5166        fragments.insert(
5167            "templateComponent".to_string(),
5168            FragmentList {
5169                fragments: vec![
5170                    WebUIFragment::raw("<component-tag>"),
5171                    WebUIFragment::component("my-component"),
5172                    WebUIFragment::raw("</component-tag>"),
5173                ],
5174            },
5175        );
5176        fragments.insert(
5177            "my-component".to_string(),
5178            FragmentList {
5179                fragments: vec![
5180                    WebUIFragment::raw("<span>"),
5181                    WebUIFragment::signal("name", false),
5182                    WebUIFragment::raw("-"),
5183                    WebUIFragment::signal("globalSuffix", false),
5184                    WebUIFragment::raw("</span>"),
5185                ],
5186            },
5187        );
5188        let protocol = WebUIProtocol::new(fragments);
5189        let state =
5190            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
5191        let mut writer = TestWriter::new();
5192        handle(
5193            &protocol,
5194            &state,
5195            &RenderOptions::new("index.html", "/"),
5196            &mut writer,
5197        )
5198        .unwrap();
5199        assert_eq!(
5200            writer.get_content(),
5201            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
5202        );
5203    }
5204
5205    #[test]
5206    fn test_component_no_item_moniker() {
5207        let mut fragments = HashMap::new();
5208        fragments.insert(
5209            "index.html".to_string(),
5210            FragmentList {
5211                fragments: vec![
5212                    WebUIFragment::raw("<div>"),
5213                    WebUIFragment::for_loop("item", "items", "templateComponent"),
5214                    WebUIFragment::raw("</div>"),
5215                ],
5216            },
5217        );
5218        fragments.insert(
5219            "templateComponent".to_string(),
5220            FragmentList {
5221                fragments: vec![
5222                    WebUIFragment::raw("<component-tag>"),
5223                    WebUIFragment::component("my-component"),
5224                    WebUIFragment::raw("</component-tag>"),
5225                ],
5226            },
5227        );
5228        fragments.insert(
5229            "my-component".to_string(),
5230            FragmentList {
5231                fragments: vec![
5232                    WebUIFragment::raw("<span>"),
5233                    WebUIFragment::signal("item.name", false),
5234                    WebUIFragment::raw("-"),
5235                    WebUIFragment::signal("globalSuffix", false),
5236                    WebUIFragment::raw("</span>"),
5237                ],
5238            },
5239        );
5240        let protocol = WebUIProtocol::new(fragments);
5241        let state =
5242            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
5243        let mut writer = TestWriter::new();
5244        handle(
5245            &protocol,
5246            &state,
5247            &RenderOptions::new("index.html", "/"),
5248            &mut writer,
5249        )
5250        .unwrap();
5251        assert_eq!(
5252            writer.get_content(),
5253            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
5254        );
5255    }
5256
5257    #[test]
5258    fn test_for_nonqualified_uses_global() {
5259        let mut fragments = HashMap::new();
5260        fragments.insert(
5261            "index.html".to_string(),
5262            FragmentList {
5263                fragments: vec![
5264                    WebUIFragment::raw("<div>"),
5265                    WebUIFragment::for_loop("item", "items", "template1"),
5266                    WebUIFragment::raw("</div>"),
5267                ],
5268            },
5269        );
5270        fragments.insert(
5271            "template1".to_string(),
5272            FragmentList {
5273                fragments: vec![
5274                    WebUIFragment::raw("<span>"),
5275                    WebUIFragment::signal("name", false),
5276                    WebUIFragment::raw("</span>"),
5277                ],
5278            },
5279        );
5280        let protocol = WebUIProtocol::new(fragments);
5281        let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
5282        let mut writer = TestWriter::new();
5283        handle(
5284            &protocol,
5285            &state,
5286            &RenderOptions::new("index.html", "/"),
5287            &mut writer,
5288        )
5289        .unwrap();
5290        assert_eq!(
5291            writer.get_content(),
5292            "<div><span>GlobalName</span><span>GlobalName</span></div>"
5293        );
5294    }
5295
5296    #[test]
5297    fn test_nested_for_if_interleaved() {
5298        let mut fragments = HashMap::new();
5299        fragments.insert(
5300            "index.html".to_string(),
5301            FragmentList {
5302                fragments: vec![
5303                    WebUIFragment::raw("<div>"),
5304                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5305                    WebUIFragment::raw("</div>"),
5306                ],
5307            },
5308        );
5309        fragments.insert(
5310            "outerTemplate".to_string(),
5311            FragmentList {
5312                fragments: vec![
5313                    WebUIFragment::raw("<section>"),
5314                    WebUIFragment::signal("globalPrefix", false),
5315                    WebUIFragment::signal("outerItem.outerLabel", false),
5316                    WebUIFragment::if_cond(
5317                        ConditionExpr::identifier("outerItem.include"),
5318                        "ifTemplate",
5319                    ),
5320                    WebUIFragment::raw("</section>"),
5321                ],
5322            },
5323        );
5324        fragments.insert(
5325            "ifTemplate".to_string(),
5326            FragmentList {
5327                fragments: vec![
5328                    WebUIFragment::raw("<div>"),
5329                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5330                    WebUIFragment::raw("</div>"),
5331                ],
5332            },
5333        );
5334        fragments.insert(
5335            "innerTemplate".to_string(),
5336            FragmentList {
5337                fragments: vec![
5338                    WebUIFragment::raw("<p>"),
5339                    WebUIFragment::signal("globalSuffix", false),
5340                    WebUIFragment::raw(": "),
5341                    WebUIFragment::signal("innerItem.innerLabel", false),
5342                    WebUIFragment::raw("</p>"),
5343                ],
5344            },
5345        );
5346        let protocol = WebUIProtocol::new(fragments);
5347        let state = test_json!({
5348            "globalPrefix": "Prefix: ",
5349            "globalSuffix": "Suffix",
5350            "outerItems": [
5351                {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
5352                {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
5353                {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
5354            ]
5355        });
5356        let mut writer = TestWriter::new();
5357        handle(
5358            &protocol,
5359            &state,
5360            &RenderOptions::new("index.html", "/"),
5361            &mut writer,
5362        )
5363        .unwrap();
5364        assert_eq!(
5365            writer.get_content(),
5366            "<div><section>Prefix: O1<div><p>Suffix: I1</p><p>Suffix: I2</p></div></section><section>Prefix: O2</section><section>Prefix: O3<div><p>Suffix: I3</p></div></section></div>"
5367        );
5368    }
5369
5370    #[test]
5371    fn test_nested_for_if_outer_state() {
5372        let mut fragments = HashMap::new();
5373        fragments.insert(
5374            "index.html".to_string(),
5375            FragmentList {
5376                fragments: vec![
5377                    WebUIFragment::raw("<div>"),
5378                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5379                    WebUIFragment::raw("</div>"),
5380                ],
5381            },
5382        );
5383        fragments.insert(
5384            "outerTemplate".to_string(),
5385            FragmentList {
5386                fragments: vec![
5387                    WebUIFragment::raw("<section>"),
5388                    WebUIFragment::signal("globalPrefix", false),
5389                    WebUIFragment::signal("outerItem.label", false),
5390                    WebUIFragment::for_loop(
5391                        "middleItem",
5392                        "outerItem.middleItems",
5393                        "middleTemplate",
5394                    ),
5395                    WebUIFragment::raw("</section>"),
5396                ],
5397            },
5398        );
5399        fragments.insert(
5400            "middleTemplate".to_string(),
5401            FragmentList {
5402                fragments: vec![
5403                    WebUIFragment::raw("<div>"),
5404                    WebUIFragment::if_cond(
5405                        ConditionExpr::identifier("outerItem.active"),
5406                        "ifTemplate",
5407                    ),
5408                    WebUIFragment::raw("</div>"),
5409                ],
5410            },
5411        );
5412        fragments.insert(
5413            "ifTemplate".to_string(),
5414            FragmentList {
5415                fragments: vec![
5416                    WebUIFragment::raw("<p>"),
5417                    WebUIFragment::signal("middleItem.value", false),
5418                    WebUIFragment::raw("</p>"),
5419                ],
5420            },
5421        );
5422        let protocol = WebUIProtocol::new(fragments);
5423        let state = test_json!({
5424            "globalPrefix": "GP-",
5425            "outerItems": [
5426                {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
5427                {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
5428                {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
5429            ]
5430        });
5431        let mut writer = TestWriter::new();
5432        handle(
5433            &protocol,
5434            &state,
5435            &RenderOptions::new("index.html", "/"),
5436            &mut writer,
5437        )
5438        .unwrap();
5439        assert_eq!(
5440            writer.get_content(),
5441            "<div><section>GP-O1<div><p>M1</p></div><div><p>M2</p></div></section><section>GP-O2<div></div></section><section>GP-O3<div><p>M4</p></div></section></div>"
5442        );
5443    }
5444
5445    #[test]
5446    fn test_nested_for_if_inner_state() {
5447        let mut fragments = HashMap::new();
5448        fragments.insert(
5449            "index.html".to_string(),
5450            FragmentList {
5451                fragments: vec![
5452                    WebUIFragment::raw("<div>"),
5453                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5454                    WebUIFragment::raw("</div>"),
5455                ],
5456            },
5457        );
5458        fragments.insert(
5459            "outerTemplate".to_string(),
5460            FragmentList {
5461                fragments: vec![
5462                    WebUIFragment::raw("<section>"),
5463                    WebUIFragment::signal("outerItem.label", false),
5464                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5465                    WebUIFragment::raw("</section>"),
5466                ],
5467            },
5468        );
5469        fragments.insert(
5470            "innerTemplate".to_string(),
5471            FragmentList {
5472                fragments: vec![
5473                    WebUIFragment::raw("<article>"),
5474                    WebUIFragment::if_cond(
5475                        ConditionExpr::identifier("innerItem.show"),
5476                        "ifTemplate",
5477                    ),
5478                    WebUIFragment::raw("</article>"),
5479                ],
5480            },
5481        );
5482        fragments.insert(
5483            "ifTemplate".to_string(),
5484            FragmentList {
5485                fragments: vec![
5486                    WebUIFragment::raw("<p>"),
5487                    WebUIFragment::signal("innerItem.detail", false),
5488                    WebUIFragment::raw("</p>"),
5489                ],
5490            },
5491        );
5492        let protocol = WebUIProtocol::new(fragments);
5493        let state = test_json!({
5494            "outerItems": [
5495                {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
5496                {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
5497            ]
5498        });
5499        let mut writer = TestWriter::new();
5500        handle(
5501            &protocol,
5502            &state,
5503            &RenderOptions::new("index.html", "/"),
5504            &mut writer,
5505        )
5506        .unwrap();
5507        assert_eq!(
5508            writer.get_content(),
5509            "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
5510        );
5511    }
5512
5513    #[test]
5514    fn test_for_merge_local_global_monikers() {
5515        let mut fragments = HashMap::new();
5516        fragments.insert(
5517            "index.html".to_string(),
5518            FragmentList {
5519                fragments: vec![
5520                    WebUIFragment::raw("<div>"),
5521                    WebUIFragment::for_loop("item", "items", "template1"),
5522                    WebUIFragment::raw("</div>"),
5523                ],
5524            },
5525        );
5526        fragments.insert(
5527            "template1".to_string(),
5528            FragmentList {
5529                fragments: vec![
5530                    WebUIFragment::raw("<span>"),
5531                    WebUIFragment::signal("item.name", false),
5532                    WebUIFragment::raw("-"),
5533                    WebUIFragment::signal("item.globalValue", false),
5534                    WebUIFragment::raw("-"),
5535                    WebUIFragment::signal("item.localOnly", false),
5536                    WebUIFragment::raw("-"),
5537                    WebUIFragment::signal("item.otherVal", false),
5538                    WebUIFragment::raw("</span>"),
5539                ],
5540            },
5541        );
5542        let protocol = WebUIProtocol::new(fragments);
5543        let state = test_json!({
5544            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
5545            "items": [
5546                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
5547                {"name": "Local2", "localOnly": "Only2"}
5548            ]
5549        });
5550        let mut writer = TestWriter::new();
5551        handle(
5552            &protocol,
5553            &state,
5554            &RenderOptions::new("index.html", "/"),
5555            &mut writer,
5556        )
5557        .unwrap();
5558        assert_eq!(
5559            writer.get_content(),
5560            "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
5561        );
5562    }
5563
5564    #[test]
5565    fn test_component_in_for_global_moniker_shadow() {
5566        let mut fragments = HashMap::new();
5567        fragments.insert(
5568            "index.html".to_string(),
5569            FragmentList {
5570                fragments: vec![
5571                    WebUIFragment::raw("<div>"),
5572                    WebUIFragment::for_loop("item", "items", "templateComponent"),
5573                    WebUIFragment::raw("</div>"),
5574                ],
5575            },
5576        );
5577        fragments.insert(
5578            "templateComponent".to_string(),
5579            FragmentList {
5580                fragments: vec![
5581                    WebUIFragment::raw("<component-tag>"),
5582                    WebUIFragment::component("my-component"),
5583                    WebUIFragment::raw("</component-tag>"),
5584                ],
5585            },
5586        );
5587        fragments.insert(
5588            "my-component".to_string(),
5589            FragmentList {
5590                fragments: vec![
5591                    WebUIFragment::raw("<span>"),
5592                    WebUIFragment::signal("name", false),
5593                    WebUIFragment::raw("-"),
5594                    WebUIFragment::signal("item.globalValue", false),
5595                    WebUIFragment::raw("-"),
5596                    WebUIFragment::signal("localOnly", false),
5597                    WebUIFragment::raw("-"),
5598                    WebUIFragment::signal("item.otherVal", false),
5599                    WebUIFragment::raw("</span>"),
5600                ],
5601            },
5602        );
5603        let protocol = WebUIProtocol::new(fragments);
5604        let state = test_json!({
5605            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
5606            "items": [
5607                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
5608                {"name": "Local2", "localOnly": "Only2"}
5609            ]
5610        });
5611        let mut writer = TestWriter::new();
5612        handle(
5613            &protocol,
5614            &state,
5615            &RenderOptions::new("index.html", "/"),
5616            &mut writer,
5617        )
5618        .unwrap();
5619        assert_eq!(
5620            writer.get_content(),
5621            "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
5622        );
5623    }
5624
5625    #[test]
5626    fn test_if_in_nested_for_local_flag() {
5627        let mut fragments = HashMap::new();
5628        fragments.insert(
5629            "index.html".to_string(),
5630            FragmentList {
5631                fragments: vec![
5632                    WebUIFragment::raw("<div>"),
5633                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
5634                    WebUIFragment::raw("</div>"),
5635                ],
5636            },
5637        );
5638        fragments.insert(
5639            "outerTemplate".to_string(),
5640            FragmentList {
5641                fragments: vec![
5642                    WebUIFragment::raw("<section>"),
5643                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
5644                    WebUIFragment::raw("</section>"),
5645                ],
5646            },
5647        );
5648        fragments.insert(
5649            "innerTemplate".to_string(),
5650            FragmentList {
5651                fragments: vec![WebUIFragment::if_cond(
5652                    ConditionExpr::identifier("inner_item.flag"),
5653                    "ifInner",
5654                )],
5655            },
5656        );
5657        fragments.insert(
5658            "ifInner".to_string(),
5659            FragmentList {
5660                fragments: vec![
5661                    WebUIFragment::raw("<p>"),
5662                    WebUIFragment::signal("inner_item.value", false),
5663                    WebUIFragment::raw("</p>"),
5664                ],
5665            },
5666        );
5667        let protocol = WebUIProtocol::new(fragments);
5668        let state = test_json!({
5669            "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
5670            "inner_item": {"flag": false}
5671        });
5672        let mut writer = TestWriter::new();
5673        handle(
5674            &protocol,
5675            &state,
5676            &RenderOptions::new("index.html", "/"),
5677            &mut writer,
5678        )
5679        .unwrap();
5680        assert_eq!(
5681            writer.get_content(),
5682            "<div><section><p>X</p></section></div>"
5683        );
5684    }
5685
5686    #[test]
5687    fn test_if_in_nested_for_global_fallback() {
5688        let mut fragments = HashMap::new();
5689        fragments.insert(
5690            "index.html".to_string(),
5691            FragmentList {
5692                fragments: vec![
5693                    WebUIFragment::raw("<div>"),
5694                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
5695                    WebUIFragment::raw("</div>"),
5696                ],
5697            },
5698        );
5699        fragments.insert(
5700            "outerTemplate".to_string(),
5701            FragmentList {
5702                fragments: vec![
5703                    WebUIFragment::raw("<section>"),
5704                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
5705                    WebUIFragment::raw("</section>"),
5706                ],
5707            },
5708        );
5709        fragments.insert(
5710            "innerTemplate".to_string(),
5711            FragmentList {
5712                fragments: vec![WebUIFragment::if_cond(
5713                    ConditionExpr::identifier("inner_item.flag"),
5714                    "ifInner",
5715                )],
5716            },
5717        );
5718        fragments.insert(
5719            "ifInner".to_string(),
5720            FragmentList {
5721                fragments: vec![
5722                    WebUIFragment::raw("<p>"),
5723                    WebUIFragment::signal("inner_item.value", false),
5724                    WebUIFragment::raw("</p>"),
5725                ],
5726            },
5727        );
5728        let protocol = WebUIProtocol::new(fragments);
5729        let state = test_json!({
5730            "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
5731            "inner_item": {"flag": true}
5732        });
5733        let mut writer = TestWriter::new();
5734        handle(
5735            &protocol,
5736            &state,
5737            &RenderOptions::new("index.html", "/"),
5738            &mut writer,
5739        )
5740        .unwrap();
5741        assert_eq!(
5742            writer.get_content(),
5743            "<div><section><p>X</p><p>Y</p></section></div>"
5744        );
5745    }
5746
5747    #[test]
5748    fn test_if_mixed_for_monikers() {
5749        let mut fragments = HashMap::new();
5750        fragments.insert(
5751            "index.html".to_string(),
5752            FragmentList {
5753                fragments: vec![
5754                    WebUIFragment::raw("<div>"),
5755                    WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
5756                    WebUIFragment::raw("</div>"),
5757                ],
5758            },
5759        );
5760        fragments.insert(
5761            "outerTemplate".to_string(),
5762            FragmentList {
5763                fragments: vec![
5764                    WebUIFragment::raw("<section>"),
5765                    WebUIFragment::signal("outer.outerLabel", false),
5766                    WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
5767                    WebUIFragment::raw("</section>"),
5768                ],
5769            },
5770        );
5771        fragments.insert(
5772            "innerTemplate".to_string(),
5773            FragmentList {
5774                fragments: vec![WebUIFragment::if_cond(
5775                    ConditionExpr::compound(
5776                        ConditionExpr::identifier("outer.active"),
5777                        LogicalOperator::And,
5778                        ConditionExpr::predicate(
5779                            "inner.value",
5780                            ComparisonOperator::GreaterThan,
5781                            "globalLimit",
5782                        ),
5783                    ),
5784                    "ifInner",
5785                )],
5786            },
5787        );
5788        fragments.insert(
5789            "ifInner".to_string(),
5790            FragmentList {
5791                fragments: vec![
5792                    WebUIFragment::raw("<p>"),
5793                    WebUIFragment::signal("inner.value", false),
5794                    WebUIFragment::raw("</p>"),
5795                ],
5796            },
5797        );
5798        let protocol = WebUIProtocol::new(fragments);
5799        let state = test_json!({
5800            "globalLimit": 10,
5801            "list": {"outerItems": [
5802                {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
5803                {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
5804                {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
5805            ]}
5806        });
5807        let mut writer = TestWriter::new();
5808        handle(
5809            &protocol,
5810            &state,
5811            &RenderOptions::new("index.html", "/"),
5812            &mut writer,
5813        )
5814        .unwrap();
5815        assert_eq!(
5816            writer.get_content(),
5817            "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
5818        );
5819    }
5820
5821    // ── Route-aware rendering tests ─────────────────────────────────────
5822
5823    fn make_route_protocol() -> WebUIProtocol {
5824        use webui_protocol::WebUiFragmentRoute;
5825
5826        let mut fragments = HashMap::new();
5827
5828        // Entry page with two routes
5829        fragments.insert(
5830            "index.html".to_string(),
5831            FragmentList {
5832                fragments: vec![
5833                    WebUIFragment::raw("<h1>Shell</h1>"),
5834                    WebUIFragment::route_from(WebUiFragmentRoute {
5835                        path: "/".into(),
5836                        fragment_id: "dash-page".into(),
5837                        exact: true,
5838                        keep_alive: false,
5839                        ..Default::default()
5840                    }),
5841                    WebUIFragment::route_from(WebUiFragmentRoute {
5842                        path: "/contacts/:id".into(),
5843                        fragment_id: "detail-page".into(),
5844                        exact: true,
5845                        keep_alive: false,
5846                        ..Default::default()
5847                    }),
5848                ],
5849            },
5850        );
5851
5852        // Dashboard page component
5853        fragments.insert(
5854            "dash-page".to_string(),
5855            FragmentList {
5856                fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
5857            },
5858        );
5859
5860        // Detail page component
5861        fragments.insert(
5862            "detail-page".to_string(),
5863            FragmentList {
5864                fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
5865            },
5866        );
5867
5868        WebUIProtocol::new(fragments)
5869    }
5870
5871    fn make_nested_route_protocol() -> WebUIProtocol {
5872        use webui_protocol::WebUiFragmentRoute;
5873
5874        let mut fragments = HashMap::new();
5875
5876        fragments.insert(
5877            "index.html".to_string(),
5878            FragmentList {
5879                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
5880                    path: "/".into(),
5881                    fragment_id: "app-shell".into(),
5882                    exact: false,
5883                    children: vec![WebUiFragmentRoute {
5884                        path: "sections/:id".into(),
5885                        fragment_id: "section-comp".into(),
5886                        exact: false,
5887                        children: vec![WebUiFragmentRoute {
5888                            path: "topics/:topicId".into(),
5889                            fragment_id: "topic-comp".into(),
5890                            exact: true,
5891                            children: vec![],
5892                            keep_alive: false,
5893                            ..Default::default()
5894                        }],
5895                        keep_alive: false,
5896                        ..Default::default()
5897                    }],
5898                    keep_alive: false,
5899                    ..Default::default()
5900                })],
5901            },
5902        );
5903
5904        fragments.insert(
5905            "app-shell".to_string(),
5906            FragmentList {
5907                fragments: vec![
5908                    WebUIFragment::raw("<h1>Shell</h1>"),
5909                    WebUIFragment::outlet(),
5910                ],
5911            },
5912        );
5913
5914        fragments.insert(
5915            "section-comp".to_string(),
5916            FragmentList {
5917                fragments: vec![
5918                    WebUIFragment::raw("<h2>Section</h2>"),
5919                    WebUIFragment::outlet(),
5920                ],
5921            },
5922        );
5923
5924        fragments.insert(
5925            "topic-comp".to_string(),
5926            FragmentList {
5927                fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
5928            },
5929        );
5930
5931        WebUIProtocol::new(fragments)
5932    }
5933
5934    #[test]
5935    fn test_route_renders_shell_always() {
5936        let protocol = make_route_protocol();
5937        let state = test_json!({});
5938        let mut writer = TestWriter::new();
5939        handle(
5940            &protocol,
5941            &state,
5942            &RenderOptions::new("index.html", "/"),
5943            &mut writer,
5944        )
5945        .unwrap();
5946        let html = writer.get_content();
5947
5948        // Shell content always renders regardless of route matching
5949        assert!(html.contains("<h1>Shell</h1>"), "shell should render");
5950        // Dashboard matches "/" so it should be active
5951        assert!(html.contains(" active>"), "matched route should be active");
5952        // Detail should be hidden and empty
5953        assert!(
5954            html.contains("style=\"display:none\""),
5955            "non-matched routes should be hidden"
5956        );
5957    }
5958
5959    #[test]
5960    fn test_route_matched_renders_visible() {
5961        let protocol = make_route_protocol();
5962        let state = test_json!({});
5963        let mut writer = TestWriter::new();
5964        handle(
5965            &protocol,
5966            &state,
5967            &RenderOptions::new("index.html", "/"),
5968            &mut writer,
5969        )
5970        .unwrap();
5971        let html = writer.get_content();
5972
5973        // Dashboard route should be visible (active, no display:none)
5974        assert!(
5975            html.contains("<webui-route path=\"/\""),
5976            "dashboard route should exist"
5977        );
5978        assert!(
5979            html.contains("active>") && html.contains("<dash-page>"),
5980            "matched route should be active with component tag: {html}"
5981        );
5982        assert!(
5983            html.contains("<p>Dashboard</p>"),
5984            "matched route should have content"
5985        );
5986    }
5987
5988    #[test]
5989    fn test_route_non_matched_renders_hidden_empty() {
5990        let protocol = make_route_protocol();
5991        let state = test_json!({});
5992        let mut writer = TestWriter::new();
5993        handle(
5994            &protocol,
5995            &state,
5996            &RenderOptions::new("index.html", "/"),
5997            &mut writer,
5998        )
5999        .unwrap();
6000        let html = writer.get_content();
6001
6002        // Detail route should be hidden and empty (no content rendered)
6003        assert!(
6004            html.contains("<webui-route path=\"/contacts/:id\""),
6005            "detail route element should exist"
6006        );
6007        // The non-matched route should have display:none and no inner content
6008        let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
6009        let after_detail = &html[detail_start..];
6010        assert!(
6011            after_detail.contains("style=\"display:none\">")
6012                && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
6013            "non-matched route should be hidden: {after_detail}"
6014        );
6015        // Should NOT contain the component's rendered content
6016        let detail_end = after_detail.find("</webui-route>").expect("closing tag");
6017        let detail_body = &after_detail[..detail_end];
6018        assert!(
6019            !detail_body.contains("<detail-page>"),
6020            "non-matched route should not render component content: {detail_body}"
6021        );
6022    }
6023
6024    #[test]
6025    fn test_route_parameterized_match() {
6026        let protocol = make_route_protocol();
6027        let state = test_json!({});
6028        let mut writer = TestWriter::new();
6029        handle(
6030            &protocol,
6031            &state,
6032            &RenderOptions::new("index.html", "/contacts/42"),
6033            &mut writer,
6034        )
6035        .unwrap();
6036        let html = writer.get_content();
6037
6038        // Detail route matches /contacts/42
6039        assert!(
6040            html.contains("active>") && html.contains("<detail-page>"),
6041            "detail route should be active: {html}"
6042        );
6043        assert!(html.contains("<p>Detail</p>"), "detail should have content");
6044        // Dashboard should be hidden + empty
6045        let dash_start = html
6046            .find("component=\"dash-page\"")
6047            .expect("dashboard route");
6048        let after_dash = &html[dash_start..];
6049        assert!(
6050            after_dash.contains("style=\"display:none\">"),
6051            "dashboard should be hidden when detail matches: {after_dash}"
6052        );
6053        let dash_end = after_dash.find("</webui-route>").expect("closing tag");
6054        let dash_body = &after_dash[..dash_end];
6055        assert!(
6056            !dash_body.contains("<dash-page>"),
6057            "dashboard should not render component content: {dash_body}"
6058        );
6059    }
6060
6061    #[test]
6062    fn test_route_no_match_all_hidden_empty() {
6063        let protocol = make_route_protocol();
6064        let state = test_json!({});
6065        let mut writer = TestWriter::new();
6066        handle(
6067            &protocol,
6068            &state,
6069            &RenderOptions::new("index.html", "/nonexistent"),
6070            &mut writer,
6071        )
6072        .unwrap();
6073        let html = writer.get_content();
6074
6075        // Shell content should still render
6076        assert!(html.contains("<h1>Shell</h1>"));
6077        // All routes should be hidden + empty (nothing matched)
6078        assert!(
6079            !html.contains("<p>Dashboard</p>"),
6080            "no route content when nothing matches"
6081        );
6082        assert!(
6083            !html.contains("<p>Detail</p>"),
6084            "no route content when nothing matches"
6085        );
6086    }
6087
6088    #[test]
6089    fn test_route_component_attr_emitted() {
6090        let protocol = make_route_protocol();
6091        let state = test_json!({});
6092        let mut writer = TestWriter::new();
6093        handle(
6094            &protocol,
6095            &state,
6096            &RenderOptions::new("index.html", "/"),
6097            &mut writer,
6098        )
6099        .unwrap();
6100        let html = writer.get_content();
6101        // component attribute should be emitted on webui-route
6102        assert!(
6103            html.contains("component=\"dash-page\""),
6104            "component attr should be on webui-route: {html}"
6105        );
6106        assert!(
6107            html.contains("component=\"detail-page\""),
6108            "component attr should be on webui-route: {html}"
6109        );
6110    }
6111
6112    #[test]
6113    fn test_no_plugin_no_state_attributes() {
6114        let protocol = make_route_protocol();
6115        let state = test_json!({
6116            "title": "Fish & Chips",
6117            "cartOpen": true,
6118            "items": [{"name": "A&B"}]
6119        });
6120        let mut writer = TestWriter::new();
6121
6122        handle(
6123            &protocol,
6124            &state,
6125            &RenderOptions::new("index.html", "/"),
6126            &mut writer,
6127        )
6128        .unwrap();
6129
6130        let html = writer.get_content();
6131        // Without a plugin, no state attributes at all
6132        assert!(
6133            !html.contains("data-state"),
6134            "no data-state without plugin: {html}"
6135        );
6136        assert!(
6137            !html.contains(r#"title="Fish"#),
6138            "no scalar attrs without plugin: {html}"
6139        );
6140    }
6141
6142    #[test]
6143    fn test_nested_routes_render_webui_route_as_light_dom() {
6144        let protocol = make_nested_route_protocol();
6145        let state = test_json!({"title": "Test"});
6146        let handler = WebUIHandler::new();
6147        let mut writer = TestWriter::new();
6148
6149        handler
6150            .handle(
6151                &protocol,
6152                &state,
6153                &RenderOptions::new("index.html", "/sections/frontend"),
6154                &mut writer,
6155            )
6156            .expect("render failed");
6157
6158        let html = writer.get_content();
6159
6160        assert!(
6161            html.contains("component=\"app-shell\"") && html.contains("active>"),
6162            "root route should be active: {html}"
6163        );
6164        // webui-route should NOT have shadow DOM — it's a light DOM structural element
6165        assert!(
6166            !html.contains("<template shadowrootmode"),
6167            "webui-route should be light DOM (no shadow template): {html}"
6168        );
6169    }
6170
6171    #[test]
6172    fn test_nested_routes_render_outlet_as_light_dom() {
6173        let protocol = make_nested_route_protocol();
6174        let state = test_json!({"title": "Test"});
6175        let handler = WebUIHandler::new();
6176        let mut writer = TestWriter::new();
6177
6178        handler
6179            .handle(
6180                &protocol,
6181                &state,
6182                &RenderOptions::new("index.html", "/sections/frontend"),
6183                &mut writer,
6184            )
6185            .expect("render failed");
6186
6187        let html = writer.get_content();
6188
6189        // No <webui-outlet> wrapper — routes render directly at outlet position
6190        assert!(
6191            !html.contains("<webui-outlet>"),
6192            "should not contain webui-outlet wrapper: {html}"
6193        );
6194        // Route elements should be in the output directly
6195        assert!(
6196            html.contains("<webui-route"),
6197            "should contain webui-route elements: {html}"
6198        );
6199    }
6200
6201    #[test]
6202    fn test_nested_routes_match_child_at_outlet() {
6203        let protocol = make_nested_route_protocol();
6204        let state = test_json!({"title": "Test"});
6205        let handler = WebUIHandler::new();
6206        let mut writer = TestWriter::new();
6207
6208        handler
6209            .handle(
6210                &protocol,
6211                &state,
6212                &RenderOptions::new("index.html", "/sections/frontend"),
6213                &mut writer,
6214            )
6215            .expect("render failed");
6216
6217        let html = writer.get_content();
6218
6219        assert!(
6220            html.contains("component=\"section-comp\"") && html.contains("active>"),
6221            "section route should be active: {html}"
6222        );
6223        assert!(
6224            html.contains("<h2>Section</h2>"),
6225            "section content should be present: {html}"
6226        );
6227    }
6228
6229    #[test]
6230    fn test_nested_routes_three_levels_deep() {
6231        let protocol = make_nested_route_protocol();
6232        let state = test_json!({"title": "Test"});
6233        let handler = WebUIHandler::new();
6234        let mut writer = TestWriter::new();
6235
6236        handler
6237            .handle(
6238                &protocol,
6239                &state,
6240                &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
6241                &mut writer,
6242            )
6243            .expect("render failed");
6244
6245        let html = writer.get_content();
6246
6247        assert!(
6248            html.contains("component=\"app-shell\"") && html.contains("active>"),
6249            "root active: {html}"
6250        );
6251        assert!(
6252            html.contains("component=\"section-comp\"") && html.contains("active>"),
6253            "section active: {html}"
6254        );
6255        assert!(
6256            html.contains("component=\"topic-comp\"")
6257                && html.contains("exact")
6258                && html.contains("active>"),
6259            "topic active: {html}"
6260        );
6261        assert!(
6262            html.contains("<p>Topic content</p>"),
6263            "leaf content present: {html}"
6264        );
6265    }
6266
6267    #[test]
6268    fn test_nested_routes_nonmatched_siblings_hidden() {
6269        let protocol = make_nested_route_protocol();
6270        let state = test_json!({"title": "Test"});
6271        let handler = WebUIHandler::new();
6272        let mut writer = TestWriter::new();
6273
6274        handler
6275            .handle(
6276                &protocol,
6277                &state,
6278                &RenderOptions::new("index.html", "/sections/frontend"),
6279                &mut writer,
6280            )
6281            .expect("render failed");
6282
6283        let html = writer.get_content();
6284
6285        assert!(
6286            html.contains(r#"component="topic-comp" exact style="display:none">"#),
6287            "topic should be hidden: {html}"
6288        );
6289    }
6290
6291    #[test]
6292    fn test_nested_routes_root_only() {
6293        let protocol = make_nested_route_protocol();
6294        let state = test_json!({"title": "Test"});
6295        let handler = WebUIHandler::new();
6296        let mut writer = TestWriter::new();
6297
6298        handler
6299            .handle(
6300                &protocol,
6301                &state,
6302                &RenderOptions::new("index.html", "/"),
6303                &mut writer,
6304            )
6305            .expect("render failed");
6306
6307        let html = writer.get_content();
6308
6309        assert!(
6310            html.contains("component=\"app-shell\"") && html.contains("active>"),
6311            "root active at /: {html}"
6312        );
6313        assert!(
6314            html.contains("<h1>Shell</h1>"),
6315            "shell renders at /: {html}"
6316        );
6317        assert!(
6318            html.contains(r#"component="section-comp" style="display:none">"#),
6319            "section hidden at /: {html}"
6320        );
6321    }
6322
6323    // ── CSS Module dedup tests ───────────────────────────────────────
6324
6325    #[test]
6326    fn test_css_module_emitted_once_inline_in_component() {
6327        // CSS module definition emitted once in the component's light DOM
6328        // on first render, not in <head> and not on second instance.
6329        let template = r#"<p><slot></slot></p>"#;
6330
6331        let mut fragments = HashMap::new();
6332        fragments.insert(
6333            "index.html".to_string(),
6334            FragmentList {
6335                fragments: vec![
6336                    WebUIFragment::raw("<html><head>".to_string()),
6337                    WebUIFragment::signal("head_end", true),
6338                    WebUIFragment::raw("</head><body><div>".to_string()),
6339                    WebUIFragment::component("my-card"),
6340                    WebUIFragment::raw("A".to_string()),
6341                    WebUIFragment::component("my-card"),
6342                    WebUIFragment::raw("B</div>".to_string()),
6343                    WebUIFragment::signal("body_end", true),
6344                    WebUIFragment::raw("</body></html>".to_string()),
6345                ],
6346            },
6347        );
6348        fragments.insert(
6349            "my-card".to_string(),
6350            FragmentList {
6351                fragments: vec![WebUIFragment::raw(template.to_string())],
6352            },
6353        );
6354
6355        let mut protocol = WebUIProtocol::new(fragments);
6356        protocol
6357            .components
6358            .entry("my-card".to_string())
6359            .or_default()
6360            .css = "p{color:red}".to_string();
6361        let state = test_json!({});
6362        let mut writer = TestWriter::new();
6363
6364        handle(
6365            &protocol,
6366            &state,
6367            &RenderOptions::new("index.html", "/"),
6368            &mut writer,
6369        )
6370        .unwrap();
6371
6372        let html = writer.get_content();
6373
6374        // CSS module importmap should appear exactly once
6375        let count = html.matches(r#"<script type="importmap""#).count();
6376        assert_eq!(
6377            count, 1,
6378            "CSS module importmap should be emitted once, got {count} in: {html}"
6379        );
6380        assert!(
6381            html.contains(r#""my-card":"data:text/css,"#),
6382            "Importmap must register my-card under a data: URI: {html}"
6383        );
6384
6385        // Template content should appear twice (once per component instance)
6386        let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
6387        assert_eq!(
6388            tmpl_count, 2,
6389            "Template should render twice, got {tmpl_count} in: {html}"
6390        );
6391
6392        // CSS module should be in <body> (inline), not in <head>
6393        let css_pos = html
6394            .find(r#"<script type="importmap""#)
6395            .expect("CSS module importmap missing");
6396        let body_pos = html.find("<body>").expect("<body> missing");
6397        assert!(
6398            css_pos > body_pos,
6399            "CSS module should be inline in component, not in <head>: {html}"
6400        );
6401    }
6402
6403    #[test]
6404    fn test_component_without_css_renders_normally() {
6405        // Components without CSS module prefix pass through unchanged
6406        let mut fragments = HashMap::new();
6407        fragments.insert(
6408            "index.html".to_string(),
6409            FragmentList {
6410                fragments: vec![WebUIFragment::component("my-card")],
6411            },
6412        );
6413        fragments.insert(
6414            "my-card".to_string(),
6415            FragmentList {
6416                fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
6417            },
6418        );
6419
6420        let protocol = WebUIProtocol::new(fragments);
6421        let state = test_json!({});
6422        let mut writer = TestWriter::new();
6423
6424        handle(
6425            &protocol,
6426            &state,
6427            &RenderOptions::new("index.html", "/"),
6428            &mut writer,
6429        )
6430        .unwrap();
6431
6432        let html = writer.get_content();
6433        assert!(
6434            html.contains("<p>hello</p>"),
6435            "Non-module component should render normally: {html}"
6436        );
6437    }
6438
6439    #[test]
6440    fn test_non_module_strategy_no_css_in_head() {
6441        // When component_css is empty (Link/Style strategies), no
6442        // CSS module importmap tags should appear in <head>.
6443        let template = r#"<p>hello</p>"#;
6444
6445        let mut fragments = HashMap::new();
6446        fragments.insert(
6447            "index.html".to_string(),
6448            FragmentList {
6449                fragments: vec![
6450                    WebUIFragment::raw("<html><head>".to_string()),
6451                    WebUIFragment::signal("head_end", true),
6452                    WebUIFragment::raw("</head><body>".to_string()),
6453                    WebUIFragment::component("my-card"),
6454                    WebUIFragment::raw("</body></html>".to_string()),
6455                ],
6456            },
6457        );
6458        fragments.insert(
6459            "my-card".to_string(),
6460            FragmentList {
6461                fragments: vec![WebUIFragment::raw(template.to_string())],
6462            },
6463        );
6464
6465        // No component css populated — simulates Link/Style strategy
6466        let protocol = WebUIProtocol::new(fragments);
6467        let state = test_json!({});
6468        let mut writer = TestWriter::new();
6469
6470        handle(
6471            &protocol,
6472            &state,
6473            &RenderOptions::new("index.html", "/"),
6474            &mut writer,
6475        )
6476        .unwrap();
6477
6478        let html = writer.get_content();
6479
6480        assert!(
6481            !html.contains(r#"<style type="module""#),
6482            "Non-module strategy should not emit legacy CSS module tags in <head>: {html}"
6483        );
6484        assert!(
6485            !html.contains(r#"<script type="importmap""#),
6486            "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
6487        );
6488        assert!(
6489            html.contains("<p>hello</p>"),
6490            "Component should still render: {html}"
6491        );
6492    }
6493
6494    #[test]
6495    fn test_style_strategy_embeds_inline_style_in_shadow_template() {
6496        let mut fragments = HashMap::new();
6497        fragments.insert(
6498            "index.html".to_string(),
6499            FragmentList {
6500                fragments: vec![
6501                    WebUIFragment::raw("<html><head>".to_string()),
6502                    WebUIFragment::signal("head_end", true),
6503                    WebUIFragment::raw("</head><body><my-card>".to_string()),
6504                    WebUIFragment::component("my-card"),
6505                    WebUIFragment::raw("</my-card></body></html>".to_string()),
6506                ],
6507            },
6508        );
6509        fragments.insert(
6510            "my-card".to_string(),
6511            FragmentList {
6512                fragments: vec![WebUIFragment::raw(
6513                    "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
6514                        .to_string(),
6515                )],
6516            },
6517        );
6518
6519        let protocol = WebUIProtocol::new(fragments);
6520        let state = test_json!({});
6521        let mut writer = TestWriter::new();
6522
6523        handle(
6524            &protocol,
6525            &state,
6526            &RenderOptions::new("index.html", "/"),
6527            &mut writer,
6528        )
6529        .unwrap();
6530
6531        let html = writer.get_content();
6532
6533        assert!(
6534            html.contains("<style>.card{color:red}</style>"),
6535            "Style strategy should embed inline CSS in shadow template: {html}"
6536        );
6537        assert!(
6538            !html.contains(r#"<style type="module""#),
6539            "Style strategy should not emit legacy module CSS in <head>: {html}"
6540        );
6541        assert!(
6542            !html.contains(r#"<script type="importmap""#),
6543            "Style strategy should not emit CSS module importmaps in <head>: {html}"
6544        );
6545    }
6546
6547    #[test]
6548    fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
6549        // Light DOM + Link strategy: handler emits <link rel="stylesheet">
6550        // in <head>. No preload tag — the stylesheet itself fetches.
6551        let mut fragments = HashMap::new();
6552        fragments.insert(
6553            "index.html".to_string(),
6554            FragmentList {
6555                fragments: vec![
6556                    WebUIFragment::raw("<html><head>".to_string()),
6557                    WebUIFragment::signal("head_end", true),
6558                    WebUIFragment::raw("</head><body><my-card>".to_string()),
6559                    WebUIFragment::component("my-card"),
6560                    WebUIFragment::raw("</my-card>".to_string()),
6561                    WebUIFragment::signal("body_end", true),
6562                    WebUIFragment::raw("</body></html>".to_string()),
6563                ],
6564            },
6565        );
6566        fragments.insert(
6567            "my-card".to_string(),
6568            FragmentList {
6569                fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
6570            },
6571        );
6572
6573        let mut protocol = WebUIProtocol::new(fragments);
6574        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
6575        protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
6576
6577        let comp = protocol
6578            .components
6579            .entry("my-card".to_string())
6580            .or_default();
6581        comp.css_href = "my-card.css".to_string();
6582        comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
6583
6584        let state = test_json!({});
6585        let mut writer = TestWriter::new();
6586
6587        handle(
6588            &protocol,
6589            &state,
6590            &RenderOptions::new("index.html", "/"),
6591            &mut writer,
6592        )
6593        .unwrap();
6594
6595        let html = writer.get_content();
6596
6597        let head_end = html.find("</head>").expect("</head> missing");
6598        let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css">"#);
6599        assert!(
6600            link_pos.is_some_and(|p| p < head_end),
6601            "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
6602        );
6603        assert!(
6604            !html.contains(r#"<link rel="preload""#),
6605            "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
6606        );
6607        assert!(
6608            !html.contains(r#"<style type="module""#),
6609            "Link strategy should not emit legacy module CSS: {html}"
6610        );
6611        assert!(
6612            !html.contains(r#"<script type="importmap""#),
6613            "Link strategy should not emit CSS module importmaps: {html}"
6614        );
6615    }
6616
6617    #[test]
6618    fn test_link_strategy_shadow_dom_emits_preload_in_head() {
6619        // Shadow DOM + Link strategy: handler emits <link rel="preload">
6620        // with data-webui-ssr-preload in <head>. No stylesheet — the shadow
6621        // root template already contains <link rel="stylesheet">.
6622        let mut fragments = HashMap::new();
6623        fragments.insert(
6624            "index.html".to_string(),
6625            FragmentList {
6626                fragments: vec![
6627                    WebUIFragment::raw("<html><head>".to_string()),
6628                    WebUIFragment::signal("head_end", true),
6629                    WebUIFragment::raw("</head><body><o-loading-state>".to_string()),
6630                    WebUIFragment::component("o-loading-state"),
6631                    WebUIFragment::raw("</o-loading-state><my-card>".to_string()),
6632                    WebUIFragment::component("my-card"),
6633                    WebUIFragment::raw("</my-card>".to_string()),
6634                    WebUIFragment::signal("body_end", true),
6635                    WebUIFragment::raw("</body></html>".to_string()),
6636                ],
6637            },
6638        );
6639        fragments.insert(
6640            "o-loading-state".to_string(),
6641            FragmentList {
6642                fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
6643            },
6644        );
6645        fragments.insert(
6646            "my-card".to_string(),
6647            FragmentList {
6648                fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
6649            },
6650        );
6651
6652        let mut protocol = WebUIProtocol::new(fragments);
6653        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
6654        protocol.set_dom_strategy(webui_protocol::DomStrategy::Shadow);
6655
6656        let comp1 = protocol
6657            .components
6658            .entry("o-loading-state".to_string())
6659            .or_default();
6660        comp1.css_href = "o-loading-state.css".to_string();
6661        comp1.template_json = r#"{"h":"<div>loading</div>"}"#.to_string();
6662
6663        let comp2 = protocol
6664            .components
6665            .entry("my-card".to_string())
6666            .or_default();
6667        comp2.css_href = "my-card.css".to_string();
6668        comp2.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
6669
6670        let state = test_json!({});
6671        let mut writer = TestWriter::new();
6672
6673        handle(
6674            &protocol,
6675            &state,
6676            &RenderOptions::new("index.html", "/"),
6677            &mut writer,
6678        )
6679        .unwrap();
6680
6681        let html = writer.get_content();
6682        let head_end = html.find("</head>").expect("</head> missing");
6683        let head_section = &html[..head_end];
6684
6685        // Both preload hints must be present with data-webui-ssr-preload attr
6686        assert!(
6687            head_section.contains(
6688                r#"<link rel="preload" href="o-loading-state.css" as="style" data-webui-ssr-preload="style">"#
6689            ),
6690            "Missing preload for o-loading-state.css in <head>: {html}"
6691        );
6692        assert!(
6693            head_section.contains(
6694                r#"<link rel="preload" href="my-card.css" as="style" data-webui-ssr-preload="style">"#
6695            ),
6696            "Missing preload for my-card.css in <head>: {html}"
6697        );
6698        // No stylesheet links — shadow root handles that
6699        assert!(
6700            !head_section.contains(r#"<link rel="stylesheet""#),
6701            "Shadow DOM should NOT emit <link rel=stylesheet> in <head>: {html}"
6702        );
6703    }
6704
6705    #[test]
6706    fn test_link_strategy_head_links_follow_document_order() {
6707        // Regression for #381: Link-strategy <head> CSS <link> tags must be
6708        // emitted in document/traversal order, not alphabetical tag order.
6709        // Document order here is <z-widget> then <a-widget>; an alphabetical
6710        // sort would (incorrectly) place a-widget first.
6711        let mut fragments = HashMap::new();
6712        fragments.insert(
6713            "index.html".to_string(),
6714            FragmentList {
6715                fragments: vec![
6716                    WebUIFragment::raw("<html><head>".to_string()),
6717                    WebUIFragment::signal("head_end", true),
6718                    WebUIFragment::raw("</head><body><z-widget>".to_string()),
6719                    WebUIFragment::component("z-widget"),
6720                    WebUIFragment::raw("</z-widget><a-widget>".to_string()),
6721                    WebUIFragment::component("a-widget"),
6722                    WebUIFragment::raw("</a-widget>".to_string()),
6723                    WebUIFragment::signal("body_end", true),
6724                    WebUIFragment::raw("</body></html>".to_string()),
6725                ],
6726            },
6727        );
6728        fragments.insert(
6729            "z-widget".to_string(),
6730            FragmentList {
6731                fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
6732            },
6733        );
6734        fragments.insert(
6735            "a-widget".to_string(),
6736            FragmentList {
6737                fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
6738            },
6739        );
6740
6741        let mut protocol = WebUIProtocol::new(fragments);
6742        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
6743        protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
6744
6745        let z = protocol
6746            .components
6747            .entry("z-widget".to_string())
6748            .or_default();
6749        z.css_href = "z-widget.css".to_string();
6750        z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
6751
6752        let a = protocol
6753            .components
6754            .entry("a-widget".to_string())
6755            .or_default();
6756        a.css_href = "a-widget.css".to_string();
6757        a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
6758
6759        let state = test_json!({});
6760        let mut writer = TestWriter::new();
6761
6762        handle(
6763            &protocol,
6764            &state,
6765            &RenderOptions::new("index.html", "/"),
6766            &mut writer,
6767        )
6768        .unwrap();
6769
6770        let html = writer.get_content();
6771        let head_end = html.find("</head>").expect("</head> missing");
6772        let head_section = &html[..head_end];
6773
6774        let z_pos = head_section
6775            .find(r#"<link rel="stylesheet" href="z-widget.css">"#)
6776            .expect("z-widget stylesheet link missing from <head>");
6777        let a_pos = head_section
6778            .find(r#"<link rel="stylesheet" href="a-widget.css">"#)
6779            .expect("a-widget stylesheet link missing from <head>");
6780
6781        assert!(
6782            z_pos < a_pos,
6783            "CSS <link> tags must follow document order (z-widget before \
6784             a-widget), not alphabetical order: {html}"
6785        );
6786    }
6787
6788    #[test]
6789    fn test_css_module_emitted_in_component_light_dom() {
6790        // CSS module <style> tags are emitted inline in the component's light DOM,
6791        // not in <head>. This keeps SSR output lean — only rendered components
6792        // get their style definitions.
6793        let mut fragments = HashMap::new();
6794        fragments.insert(
6795            "index.html".to_string(),
6796            FragmentList {
6797                fragments: vec![
6798                    WebUIFragment::raw("<html><head>".to_string()),
6799                    WebUIFragment::signal("head_end", true),
6800                    WebUIFragment::raw("</head><body><my-card>".to_string()),
6801                    WebUIFragment::component("my-card"),
6802                    WebUIFragment::raw("</my-card>".to_string()),
6803                    WebUIFragment::raw("</body></html>".to_string()),
6804                ],
6805            },
6806        );
6807        fragments.insert(
6808            "my-card".to_string(),
6809            FragmentList {
6810                fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
6811            },
6812        );
6813
6814        let mut protocol = WebUIProtocol::new(fragments);
6815        protocol
6816            .components
6817            .entry("my-card".to_string())
6818            .or_default()
6819            .css = "p{color:red}".to_string();
6820        let state = test_json!({});
6821        let mut writer = TestWriter::new();
6822
6823        handle(
6824            &protocol,
6825            &state,
6826            &RenderOptions::new("index.html", "/"),
6827            &mut writer,
6828        )
6829        .unwrap();
6830
6831        let html = writer.get_content();
6832
6833        // CSS module importmap must be INSIDE the component tag (light DOM)
6834        let tag_open = html.find("<my-card>").expect("<my-card> missing");
6835        let css_pos = html
6836            .find(r#"<script type="importmap""#)
6837            .expect("CSS module importmap missing");
6838        let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
6839        assert!(
6840            css_pos > tag_open && css_pos < tag_close,
6841            "CSS module should be inside component light DOM: {html}"
6842        );
6843
6844        // <head> should NOT contain module styles
6845        let head_end = html.find("</head>").expect("</head> missing");
6846        assert!(
6847            css_pos > head_end,
6848            "CSS module should not be in <head>: {html}"
6849        );
6850    }
6851
6852    #[test]
6853    fn test_css_module_emitted_for_route_components() {
6854        // Route components get CSS modules emitted inline in their light DOM.
6855        let template = r#"<h1>Dashboard</h1>"#;
6856
6857        let mut fragments = HashMap::new();
6858        fragments.insert(
6859            "index.html".to_string(),
6860            FragmentList {
6861                fragments: vec![
6862                    WebUIFragment::raw("<html><head>".to_string()),
6863                    WebUIFragment::signal("head_end", true),
6864                    WebUIFragment::raw("</head><body>".to_string()),
6865                    WebUIFragment::route("/", "dash-page"),
6866                    WebUIFragment::signal("body_end", true),
6867                    WebUIFragment::raw("</body></html>".to_string()),
6868                ],
6869            },
6870        );
6871        fragments.insert(
6872            "dash-page".to_string(),
6873            FragmentList {
6874                fragments: vec![WebUIFragment::raw(template.to_string())],
6875            },
6876        );
6877
6878        let mut protocol = WebUIProtocol::new(fragments);
6879        let comp = protocol
6880            .components
6881            .entry("dash-page".to_string())
6882            .or_default();
6883        comp.css = "h1{font-size:2rem}".to_string();
6884        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
6885        let state = test_json!({});
6886        let mut writer = TestWriter::new();
6887
6888        handle(
6889            &protocol,
6890            &state,
6891            &RenderOptions::new("index.html", "/"),
6892            &mut writer,
6893        )
6894        .unwrap();
6895
6896        let html = writer.get_content();
6897
6898        assert!(
6899            html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
6900            "Route component should have CSS module importmap with data: URI: {html}"
6901        );
6902        assert!(
6903            html.contains("<h1>Dashboard</h1>"),
6904            "Route component should render content: {html}"
6905        );
6906    }
6907
6908    #[test]
6909    fn test_head_css_link_skipped_for_components_without_css() {
6910        // Regression: components without CSS files must not get <link> tags
6911        // in <head>, otherwise the browser requests a 404.
6912        let mut fragments = HashMap::new();
6913        fragments.insert(
6914            "index.html".to_string(),
6915            FragmentList {
6916                fragments: vec![
6917                    WebUIFragment::raw("<html><head>".to_string()),
6918                    WebUIFragment::signal("head_end", true),
6919                    WebUIFragment::raw("</head><body>".to_string()),
6920                    WebUIFragment::component("has-css"),
6921                    WebUIFragment::component("no-css"),
6922                    WebUIFragment::signal("body_end", true),
6923                    WebUIFragment::raw("</body></html>".to_string()),
6924                ],
6925            },
6926        );
6927        fragments.insert(
6928            "has-css".to_string(),
6929            FragmentList {
6930                fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
6931            },
6932        );
6933        fragments.insert(
6934            "no-css".to_string(),
6935            FragmentList {
6936                fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
6937            },
6938        );
6939
6940        let mut protocol = WebUIProtocol::new(fragments);
6941        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
6942        protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
6943
6944        // Only has-css has an external stylesheet (Link strategy)
6945        protocol
6946            .components
6947            .entry("has-css".to_string())
6948            .or_default()
6949            .css_href = "has-css.css".to_string();
6950
6951        let state = test_json!({});
6952        let mut writer = TestWriter::new();
6953
6954        let handler = WebUIHandler::with_plugin(|| {
6955            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
6956        });
6957        handler
6958            .handle(
6959                &protocol,
6960                &state,
6961                &RenderOptions::new("index.html", "/"),
6962                &mut writer,
6963            )
6964            .unwrap();
6965
6966        let html = writer.get_content();
6967        assert!(
6968            html.contains(r#"<link rel="stylesheet" href="has-css.css">"#),
6969            "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
6970        );
6971        assert!(
6972            !html.contains("no-css.css"),
6973            "Component without CSS must NOT get a <link> in <head>: {html}"
6974        );
6975    }
6976
6977    #[test]
6978    fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
6979        // Simulates a page where app-shell renders cart-panel, but cart-panel
6980        // contains an <if> block with product-card inside. When the condition
6981        // is false (empty cart), product-card is NOT rendered — but it IS
6982        // reachable from the fragment graph. Its template metadata and CSS module
6983        // definition must be in the output so the client can mount it when
6984        // the <if> flips true. However, its bit must NOT be set in the
6985        // inventory — the inventory tracks what was actually rendered.
6986        let mut fragments = HashMap::new();
6987        fragments.insert(
6988            "index.html".to_string(),
6989            FragmentList {
6990                fragments: vec![
6991                    WebUIFragment::raw("<html><head>".to_string()),
6992                    WebUIFragment::signal("head_end", true),
6993                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
6994                    WebUIFragment::component("app-shell"),
6995                    WebUIFragment::raw("</app-shell>".to_string()),
6996                    WebUIFragment::signal("body_end", true),
6997                    WebUIFragment::raw("</body></html>".to_string()),
6998                ],
6999            },
7000        );
7001        // app-shell contains a cart panel
7002        fragments.insert(
7003            "app-shell".to_string(),
7004            FragmentList {
7005                fragments: vec![
7006                    WebUIFragment::raw("<div>Shell</div>".to_string()),
7007                    WebUIFragment::component("cart-panel"),
7008                ],
7009            },
7010        );
7011        // cart-panel has an <if> block containing product-card
7012        fragments.insert(
7013            "cart-panel".to_string(),
7014            FragmentList {
7015                fragments: vec![
7016                    WebUIFragment::raw("<aside>".to_string()),
7017                    WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
7018                    WebUIFragment::raw("</aside>".to_string()),
7019                ],
7020            },
7021        );
7022        // cart-items (if block body) contains product-card
7023        fragments.insert(
7024            "cart-items".to_string(),
7025            FragmentList {
7026                fragments: vec![WebUIFragment::component("product-card")],
7027            },
7028        );
7029        fragments.insert(
7030            "product-card".to_string(),
7031            FragmentList {
7032                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
7033            },
7034        );
7035
7036        let mut protocol = WebUIProtocol::new(fragments);
7037        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7038        for name in ["app-shell", "cart-panel", "product-card"] {
7039            let comp = protocol.components.entry(name.to_string()).or_default();
7040            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
7041            comp.css = format!(".{name}{{display:block}}");
7042            if name == "cart-panel" {
7043                comp.hydration_mode = StateProjectionMode::Keys as i32;
7044                comp.hydration_keys = vec!["hasItems".to_string()];
7045            }
7046            if name == "product-card" {
7047                comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
7048            }
7049        }
7050
7051        // Render with hasItems=false — product-card should NOT be rendered
7052        let state = test_json!({ "hasItems": false });
7053        let mut writer = TestWriter::new();
7054
7055        let handler = WebUIHandler::with_plugin(|| {
7056            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7057        });
7058        handler
7059            .handle(
7060                &protocol,
7061                &state,
7062                &RenderOptions::new("index.html", "/"),
7063                &mut writer,
7064            )
7065            .unwrap();
7066
7067        let html = writer.get_content();
7068
7069        assert!(
7070            html.contains(r#"<script type="application/json" id="webui-data">"#),
7071            "non-executable SSR metadata should be emitted in the webui-data block: {html}"
7072        );
7073        assert!(
7074            html.contains(r#""state":{"hasItems":false}"#),
7075            "SSR state should live in the JSON data block: {html}"
7076        );
7077        assert!(
7078            html.contains(r#""inventory":"#),
7079            "SSR inventory should live in the JSON data block: {html}"
7080        );
7081        assert!(
7082            !html.contains("window.__webui={\""),
7083            "executable bootstrap must not embed the window.__webui JSON literal: {html}"
7084        );
7085        assert!(
7086            !html.contains(r#"document.getElementById("webui-data")"#),
7087            "SSR must not parse webui-data; client packages own that lazy load: {html}"
7088        );
7089        assert!(
7090            !html.contains("window.__webui=w;"),
7091            "executable bootstrap must not replace existing window.__webui registrations: {html}"
7092        );
7093        assert!(
7094            !html.contains("w.templateFns={\""),
7095            "template function emission must not replace existing templateFns registrations: {html}"
7096        );
7097        assert!(
7098            html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
7099            "template functions should merge into the flat templateFns registry: {html}"
7100        );
7101
7102        // product-card template IS in the output — it's a known component
7103        // whose template must be available for client-side <if> activation.
7104        assert!(
7105            html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
7106            "product-card template should be emitted even when unrendered: {html}"
7107        );
7108
7109        // product-card CSS module IS in the output — reachable components need
7110        // their stylesheet definitions for client-side <if> activation.
7111        assert!(
7112            html.contains(r#""product-card":"data:text/css,"#),
7113            "reachable product-card CSS module importmap should be emitted: {html}"
7114        );
7115
7116        // app-shell and cart-panel SHOULD be in the output (they were rendered)
7117        assert!(
7118            html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
7119            "rendered app-shell template should be emitted: {html}"
7120        );
7121        assert!(
7122            html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
7123            "rendered cart-panel template should be emitted: {html}"
7124        );
7125    }
7126
7127    // ── CSP nonce on CSS module importmap ───────────────────────────
7128    //
7129    // When `RenderOptions::with_nonce(...)` is set, every inline
7130    // `<script type="importmap">` definition emitted during SSR for a
7131    // component CSS module must include `nonce="VALUE"` so strict CSP
7132    // `script-src 'nonce-...'` policies allow it. The without-nonce case
7133    // is already covered by other CSS module tests (e.g.
7134    // `test_css_module_emitted_for_route_components`).
7135
7136    #[test]
7137    fn test_css_module_emits_nonce_attribute_when_nonce_set() {
7138        // Per-component first-render path (`emit_css_module`).
7139        let template = r#"<h1>Dashboard</h1>"#;
7140
7141        let mut fragments = HashMap::new();
7142        fragments.insert(
7143            "index.html".to_string(),
7144            FragmentList {
7145                fragments: vec![
7146                    WebUIFragment::raw("<html><head>".to_string()),
7147                    WebUIFragment::signal("head_end", true),
7148                    WebUIFragment::raw("</head><body>".to_string()),
7149                    WebUIFragment::route("/", "dash-page"),
7150                    WebUIFragment::signal("body_end", true),
7151                    WebUIFragment::raw("</body></html>".to_string()),
7152                ],
7153            },
7154        );
7155        fragments.insert(
7156            "dash-page".to_string(),
7157            FragmentList {
7158                fragments: vec![WebUIFragment::raw(template.to_string())],
7159            },
7160        );
7161
7162        let mut protocol = WebUIProtocol::new(fragments);
7163        let comp = protocol
7164            .components
7165            .entry("dash-page".to_string())
7166            .or_default();
7167        comp.css = "h1{font-size:2rem}".to_string();
7168        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
7169        let state = test_json!({});
7170        let mut writer = TestWriter::new();
7171
7172        handle(
7173            &protocol,
7174            &state,
7175            &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
7176            &mut writer,
7177        )
7178        .unwrap();
7179
7180        let html = writer.get_content();
7181
7182        assert!(
7183            html.contains(
7184                r#"<script type="importmap" nonce="test-nonce-123">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
7185            ),
7186            "CSS module importmap tag should include nonce attribute in canonical order: {html}"
7187        );
7188    }
7189
7190    #[test]
7191    fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
7192        // Body-end emission path for reachable-but-unrendered components
7193        // (the second site touched by the patch). Triggered via a false
7194        // `<if>` block under hydration; requires the WebUI plugin so the
7195        // body_end hook executes.
7196        let mut fragments = HashMap::new();
7197        fragments.insert(
7198            "index.html".to_string(),
7199            FragmentList {
7200                fragments: vec![
7201                    WebUIFragment::raw("<html><head>".to_string()),
7202                    WebUIFragment::signal("head_end", true),
7203                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
7204                    WebUIFragment::component("app-shell"),
7205                    WebUIFragment::raw("</app-shell>".to_string()),
7206                    WebUIFragment::signal("body_end", true),
7207                    WebUIFragment::raw("</body></html>".to_string()),
7208                ],
7209            },
7210        );
7211        fragments.insert(
7212            "app-shell".to_string(),
7213            FragmentList {
7214                fragments: vec![WebUIFragment::if_cond(
7215                    ConditionExpr::identifier("hasItems"),
7216                    "cart-items",
7217                )],
7218            },
7219        );
7220        fragments.insert(
7221            "cart-items".to_string(),
7222            FragmentList {
7223                fragments: vec![WebUIFragment::component("product-card")],
7224            },
7225        );
7226        fragments.insert(
7227            "product-card".to_string(),
7228            FragmentList {
7229                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
7230            },
7231        );
7232
7233        let mut protocol = WebUIProtocol::new(fragments);
7234        for name in ["app-shell", "product-card"] {
7235            let comp = protocol.components.entry(name.to_string()).or_default();
7236            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
7237            comp.css = format!(".{name}{{display:block}}");
7238        }
7239
7240        // Render with hasItems=false so product-card is reachable but not
7241        // rendered, forcing its CSS module emission through the body_end path.
7242        let state = test_json!({ "hasItems": false });
7243        let mut writer = TestWriter::new();
7244
7245        let handler = WebUIHandler::with_plugin(|| {
7246            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7247        });
7248        handler
7249            .handle(
7250                &protocol,
7251                &state,
7252                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
7253                &mut writer,
7254            )
7255            .unwrap();
7256
7257        let html = writer.get_content();
7258
7259        assert!(
7260            html.contains(
7261                r#"<script type="importmap" nonce="test-nonce-123">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
7262            ),
7263            "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
7264        );
7265    }
7266
7267    #[test]
7268    fn projected_state_excludes_non_hydration_keys() -> Result<()> {
7269        let mut fragments = HashMap::new();
7270        fragments.insert(
7271            "index.html".to_string(),
7272            FragmentList {
7273                fragments: vec![
7274                    WebUIFragment::raw("<html><body><style>".to_string()),
7275                    WebUIFragment::signal("tokens.light", true),
7276                    WebUIFragment::raw("</style>".to_string()),
7277                    WebUIFragment::signal("body_end", true),
7278                    WebUIFragment::raw("</body></html>".to_string()),
7279                ],
7280            },
7281        );
7282        fragments.insert(
7283            "app-shell".to_string(),
7284            FragmentList {
7285                fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
7286            },
7287        );
7288        let index_fragments = fragments
7289            .get_mut("index.html")
7290            .expect("index fixture should exist");
7291        index_fragments
7292            .fragments
7293            .insert(1, WebUIFragment::component("app-shell"));
7294        let mut protocol = WebUIProtocol::new(fragments);
7295        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7296        // Only `name` is a hydration key. `tokens` is a server-only field
7297        // (used above to resolve SSR CSS variables) and is NOT in the component
7298        // hydration keys,
7299        // so projection MUST keep it out of the client state block.
7300        protocol.components.insert(
7301            "app-shell".to_string(),
7302            webui_protocol::ComponentData {
7303                hydration_mode: StateProjectionMode::Keys as i32,
7304                hydration_keys: vec!["name".to_string()],
7305                ..Default::default()
7306            },
7307        );
7308        let state = test_json!({
7309            "name": "Alice",
7310            "tokens": {
7311                "light": "--color-brand: red;"
7312            }
7313        });
7314        let mut writer = TestWriter::new();
7315        let handler = WebUIHandler::with_plugin(|| {
7316            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7317        });
7318        handler.handle(
7319            &protocol,
7320            &state,
7321            &RenderOptions::new("index.html", "/"),
7322            &mut writer,
7323        )?;
7324        let output = writer.get_content();
7325
7326        // SSR still reads `tokens` to resolve the inline <style>...
7327        assert!(output.contains("--color-brand: red;"));
7328        // ...but only the hydration key reaches the client state.
7329        assert!(output.contains(r#""name":"Alice""#));
7330        assert!(!output.contains(r#""tokens""#));
7331        Ok(())
7332    }
7333
7334    #[test]
7335    fn full_initial_strategy_preserves_complete_state() -> Result<()> {
7336        let mut fragments = HashMap::new();
7337        fragments.insert(
7338            "index.html".to_string(),
7339            FragmentList {
7340                fragments: vec![
7341                    WebUIFragment::raw("<html><body>"),
7342                    WebUIFragment::signal("body_end", true),
7343                    WebUIFragment::raw("</body></html>"),
7344                ],
7345            },
7346        );
7347        let protocol = WebUIProtocol::new(fragments);
7348        let state = test_json!({
7349            "client": "visible",
7350            "serverOnly": "also preserved",
7351        });
7352        let handler = WebUIHandler::with_plugin(|| {
7353            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7354        });
7355        let mut writer = TestWriter::new();
7356        handler.handle(
7357            &protocol,
7358            &state,
7359            &RenderOptions::new("index.html", "/"),
7360            &mut writer,
7361        )?;
7362        let output = writer.get_content();
7363        assert!(output.contains(r#""client":"visible""#));
7364        assert!(output.contains(r#""serverOnly":"also preserved""#));
7365        Ok(())
7366    }
7367
7368    #[test]
7369    fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
7370        let mut fragments = HashMap::new();
7371        fragments.insert(
7372            "index.html".to_string(),
7373            FragmentList {
7374                fragments: vec![
7375                    WebUIFragment::raw("<html><body>"),
7376                    WebUIFragment::component("app-shell"),
7377                    WebUIFragment::signal("body_end", true),
7378                    WebUIFragment::raw("</body></html>"),
7379                ],
7380            },
7381        );
7382        fragments.insert(
7383            "app-shell".to_string(),
7384            FragmentList {
7385                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
7386            },
7387        );
7388        let mut protocol = WebUIProtocol::new(fragments);
7389        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7390        protocol.components.insert(
7391            "app-shell".to_string(),
7392            webui_protocol::ComponentData {
7393                hydration_mode: StateProjectionMode::All as i32,
7394                ..Default::default()
7395            },
7396        );
7397        let state = test_json!({
7398            "known": "value",
7399            "possiblyInherited": "must not be dropped",
7400        });
7401        let handler = WebUIHandler::with_plugin(|| {
7402            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7403        });
7404        let mut writer = TestWriter::new();
7405        handler.handle(
7406            &protocol,
7407            &state,
7408            &RenderOptions::new("index.html", "/"),
7409            &mut writer,
7410        )?;
7411        let output = writer.get_content();
7412        assert!(output.contains(r#""known":"value""#));
7413        assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
7414        Ok(())
7415    }
7416
7417    #[test]
7418    fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
7419        let mut fragments = HashMap::new();
7420        fragments.insert(
7421            "index.html".to_string(),
7422            FragmentList {
7423                fragments: vec![
7424                    WebUIFragment::raw("<html><body>"),
7425                    WebUIFragment::component("app-shell"),
7426                    WebUIFragment::signal("body_end", true),
7427                    WebUIFragment::raw("</body></html>"),
7428                ],
7429            },
7430        );
7431        fragments.insert(
7432            "app-shell".to_string(),
7433            FragmentList {
7434                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
7435            },
7436        );
7437        let mut protocol = WebUIProtocol::new(fragments);
7438        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7439        let state = test_json!({
7440            "known": "value",
7441            "serverOnly": "must not be dropped",
7442        });
7443        let handler = WebUIHandler::with_plugin(|| {
7444            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7445        });
7446        let mut writer = TestWriter::new();
7447        handler.handle(
7448            &protocol,
7449            &state,
7450            &RenderOptions::new("index.html", "/"),
7451            &mut writer,
7452        )?;
7453        let output = writer.get_content();
7454        assert!(output.contains(r#""known":"value""#));
7455        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
7456        Ok(())
7457    }
7458
7459    #[test]
7460    fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
7461        let mut fragments = HashMap::new();
7462        fragments.insert(
7463            "index.html".to_string(),
7464            FragmentList {
7465                fragments: vec![
7466                    WebUIFragment::raw("<html><body>"),
7467                    WebUIFragment::component("app-shell"),
7468                    WebUIFragment::signal("body_end", true),
7469                    WebUIFragment::raw("</body></html>"),
7470                ],
7471            },
7472        );
7473        fragments.insert(
7474            "app-shell".to_string(),
7475            FragmentList {
7476                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
7477            },
7478        );
7479        let mut protocol = WebUIProtocol::new(fragments);
7480        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7481        protocol.components.insert(
7482            "app-shell".to_string(),
7483            webui_protocol::ComponentData {
7484                hydration_mode: i32::MAX,
7485                ..Default::default()
7486            },
7487        );
7488        let state = test_json!({
7489            "known": "value",
7490            "serverOnly": "must not be dropped",
7491        });
7492        let handler = WebUIHandler::with_plugin(|| {
7493            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7494        });
7495        let mut writer = TestWriter::new();
7496        handler.handle(
7497            &protocol,
7498            &state,
7499            &RenderOptions::new("index.html", "/"),
7500            &mut writer,
7501        )?;
7502        let output = writer.get_content();
7503        assert!(output.contains(r#""known":"value""#));
7504        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
7505        Ok(())
7506    }
7507
7508    #[test]
7509    fn legacy_navigation_keys_with_default_mode_remain_keyed() {
7510        let mut protocol = WebUIProtocol::new(HashMap::new());
7511        protocol.components.insert(
7512            "app-shell".to_string(),
7513            webui_protocol::ComponentData {
7514                navigation_keys: vec!["selected".to_string()],
7515                ..Default::default()
7516            },
7517        );
7518
7519        match collect_navigation_state(&protocol, ["app-shell"]) {
7520            StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
7521            StateSelection::Full => panic!("legacy navigation keys should remain projected"),
7522        }
7523    }
7524
7525    #[test]
7526    fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
7527        let mut fragments = HashMap::new();
7528        fragments.insert(
7529            "index.html".to_string(),
7530            FragmentList {
7531                fragments: vec![
7532                    WebUIFragment::raw("<html><body>".to_string()),
7533                    WebUIFragment::signal("body_end", true),
7534                    WebUIFragment::raw("</body></html>".to_string()),
7535                ],
7536            },
7537        );
7538        let mut protocol = WebUIProtocol::new(fragments);
7539        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7540        let state = test_json!({
7541            "title": "Legacy state",
7542            "serverOnly": "preserved",
7543        });
7544        let handler = WebUIHandler::with_plugin(|| {
7545            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7546        });
7547
7548        let mut writer = TestWriter::new();
7549        handler.handle(
7550            &protocol,
7551            &state,
7552            &RenderOptions::new("index.html", "/"),
7553            &mut writer,
7554        )?;
7555        assert!(writer.get_content().contains(r#""state":{}"#));
7556        assert!(!writer.get_content().contains("Legacy state"));
7557        assert!(!writer.get_content().contains("preserved"));
7558        Ok(())
7559    }
7560
7561    #[test]
7562    fn scriptless_component_state_is_navigation_only() -> Result<()> {
7563        let mut fragments = HashMap::new();
7564        fragments.insert(
7565            "index.html".to_string(),
7566            FragmentList {
7567                fragments: vec![
7568                    WebUIFragment::raw("<html><body>"),
7569                    WebUIFragment::component("items-page"),
7570                    WebUIFragment::signal("body_end", true),
7571                    WebUIFragment::raw("</body></html>"),
7572                ],
7573            },
7574        );
7575        fragments.insert(
7576            "items-page".to_string(),
7577            FragmentList {
7578                fragments: vec![WebUIFragment::raw("<p>Items</p>")],
7579            },
7580        );
7581        let mut protocol = WebUIProtocol::new(fragments);
7582        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7583        protocol.components.insert(
7584            "items-page".to_string(),
7585            webui_protocol::ComponentData {
7586                template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
7587                navigation_mode: StateProjectionMode::Keys as i32,
7588                navigation_keys: vec!["items".into()],
7589                ..Default::default()
7590            },
7591        );
7592        let state = test_json!({
7593            "items": ["STATE_SENTINEL"],
7594            "serverOnly": "SECRET_SENTINEL",
7595        });
7596        let handler = WebUIHandler::with_plugin(|| {
7597            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7598        });
7599        let mut writer = TestWriter::new();
7600
7601        handler.handle(
7602            &protocol,
7603            &state,
7604            &RenderOptions::new("index.html", "/"),
7605            &mut writer,
7606        )?;
7607
7608        let output = writer.get_content();
7609        assert!(output.contains(r#""state":{}"#));
7610        assert!(!output.contains("STATE_SENTINEL"));
7611        assert!(!output.contains("SECRET_SENTINEL"));
7612        Ok(())
7613    }
7614
7615    #[test]
7616    fn write_selected_state_projects_and_escapes() {
7617        // `keep` is in the sorted key set and its value contains a `</` that
7618        // must be escaped; `drop` is absent and must be projected out.
7619        let state = test_json!({
7620            "drop": "secret",
7621            "keep": "</script><b>"
7622        });
7623        let keys = ["keep"];
7624        let selection = StateSelection::Keys(keys.to_vec());
7625        let mut sink = TestWriter::new();
7626        write_selected_state(&mut sink, &state, &selection).unwrap();
7627        assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
7628    }
7629
7630    #[test]
7631    fn write_selected_state_non_object_projection_emits_empty_object() {
7632        let state = test_json!("scalar state has nothing hydratable");
7633        let keys: [&str; 0] = [];
7634        let selection = StateSelection::Keys(keys.to_vec());
7635        let mut sink = TestWriter::new();
7636        write_selected_state(&mut sink, &state, &selection).unwrap();
7637        assert_eq!(sink.get_content(), "{}");
7638    }
7639
7640    #[test]
7641    fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
7642        let state = test_json!({
7643            "keptA": 1,
7644            "keptB": 2,
7645            "serverOnlyA": 3,
7646            "serverOnlyB": 4,
7647        });
7648        let keys = ["keptA", "keptA", "keptB", "missing"];
7649        let selection = StateSelection::Keys(keys.to_vec());
7650        let mut sink = TestWriter::new();
7651        write_selected_state(&mut sink, &state, &selection).unwrap();
7652        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
7653    }
7654
7655    #[test]
7656    fn write_selected_state_map_first_matches_schema_first_output() {
7657        let state = test_json!({
7658            "keptA": 1,
7659            "keptB": 2,
7660        });
7661        let keys = ["keptA", "keptB", "missingA", "missingB"];
7662        let selection = StateSelection::Keys(keys.to_vec());
7663        let mut sink = TestWriter::new();
7664        write_selected_state(&mut sink, &state, &selection).unwrap();
7665        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
7666    }
7667
7668    #[test]
7669    fn write_selected_state_full_preserves_and_escapes_state() {
7670        let state = test_json!({
7671            "serverOnly": "</script><b>",
7672            "value": 42,
7673        });
7674        let mut sink = TestWriter::new();
7675        write_selected_state(&mut sink, &state, &StateSelection::Full).unwrap();
7676        assert_eq!(
7677            sink.get_content(),
7678            r#"{"serverOnly":"<\/script><b>","value":42}"#
7679        );
7680    }
7681
7682    #[test]
7683    fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
7684        let mut fragments = HashMap::new();
7685        fragments.insert(
7686            "index.html".to_string(),
7687            FragmentList {
7688                fragments: vec![
7689                    WebUIFragment::raw("<html><body>"),
7690                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
7691                        path: "/".to_string(),
7692                        fragment_id: "home-page".to_string(),
7693                        exact: true,
7694                        ..Default::default()
7695                    }),
7696                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
7697                        path: "/admin".to_string(),
7698                        fragment_id: "admin-page".to_string(),
7699                        exact: true,
7700                        ..Default::default()
7701                    }),
7702                    WebUIFragment::signal("body_end", true),
7703                    WebUIFragment::raw("</body></html>"),
7704                ],
7705            },
7706        );
7707        fragments.insert(
7708            "home-page".to_string(),
7709            FragmentList {
7710                fragments: vec![WebUIFragment::raw("<p>Home</p>")],
7711            },
7712        );
7713        fragments.insert(
7714            "admin-page".to_string(),
7715            FragmentList {
7716                fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
7717            },
7718        );
7719
7720        let mut protocol = WebUIProtocol::new(fragments);
7721        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7722        protocol.components.insert(
7723            "home-page".to_string(),
7724            webui_protocol::ComponentData {
7725                template_json: "{}".to_string(),
7726                hydration_mode: StateProjectionMode::Keys as i32,
7727                hydration_keys: vec!["homeTitle".to_string()],
7728                ..Default::default()
7729            },
7730        );
7731        protocol.components.insert(
7732            "admin-page".to_string(),
7733            webui_protocol::ComponentData {
7734                template_json: "{}".to_string(),
7735                hydration_mode: StateProjectionMode::Keys as i32,
7736                hydration_keys: vec!["adminToken".to_string()],
7737                ..Default::default()
7738            },
7739        );
7740        let state = test_json!({
7741            "homeTitle": "Welcome",
7742            "adminToken": "TOP_SECRET_SENTINEL",
7743        });
7744        let handler = WebUIHandler::with_plugin(|| {
7745            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7746        });
7747        let mut writer = TestWriter::new();
7748        handler.handle(
7749            &protocol,
7750            &state,
7751            &RenderOptions::new("index.html", "/"),
7752            &mut writer,
7753        )?;
7754        let output = writer.get_content();
7755        assert!(output.contains(r#""homeTitle":"Welcome""#));
7756        assert!(!output.contains("TOP_SECRET_SENTINEL"));
7757        assert!(!output.contains(r#""adminToken""#));
7758        Ok(())
7759    }
7760
7761    #[test]
7762    fn test_component_attr_name_aria() {
7763        // component_attr_name correctly maps ARIA attributes via the shared table
7764        assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
7765        assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
7766        assert_eq!(
7767            component_attr_name("aria-activedescendant"),
7768            "ariaActiveDescendant"
7769        );
7770        assert_eq!(component_attr_name("aria-label"), "ariaLabel");
7771        assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
7772    }
7773
7774    #[test]
7775    fn test_component_attr_name_html_global() {
7776        assert_eq!(component_attr_name("readonly"), "readOnly");
7777        assert_eq!(component_attr_name("tabindex"), "tabIndex");
7778        assert_eq!(component_attr_name("accesskey"), "accessKey");
7779        assert_eq!(component_attr_name("contenteditable"), "contentEditable");
7780        assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
7781        assert_eq!(component_attr_name("inputmode"), "inputMode");
7782        assert_eq!(component_attr_name("maxlength"), "maxLength");
7783        assert_eq!(component_attr_name("minlength"), "minLength");
7784        assert_eq!(component_attr_name("novalidate"), "noValidate");
7785        assert_eq!(component_attr_name("formaction"), "formAction");
7786        assert_eq!(component_attr_name("ismap"), "isMap");
7787        assert_eq!(component_attr_name("usemap"), "useMap");
7788    }
7789
7790    #[test]
7791    fn test_component_attr_name_strips_colon() {
7792        assert_eq!(component_attr_name(":readonly"), "readOnly");
7793        assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
7794        assert_eq!(component_attr_name(":data-title"), "dataTitle");
7795    }
7796
7797    #[test]
7798    fn test_component_attr_name_regular() {
7799        assert_eq!(component_attr_name("data-title"), "dataTitle");
7800        assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
7801        assert_eq!(component_attr_name("simple"), "simple");
7802    }
7803
7804    // ── allowed_query SSR emission tests ─────────────────────────────
7805
7806    fn make_query_route_protocol() -> WebUIProtocol {
7807        use webui_protocol::WebUiFragmentRoute;
7808
7809        let mut fragments = HashMap::new();
7810
7811        fragments.insert(
7812            "index.html".to_string(),
7813            FragmentList {
7814                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
7815                    path: "/".into(),
7816                    fragment_id: "app-shell".into(),
7817                    exact: false,
7818                    children: vec![
7819                        WebUiFragmentRoute {
7820                            path: "compose".into(),
7821                            fragment_id: "compose-page".into(),
7822                            exact: true,
7823                            allowed_query: "action,to,subject".into(),
7824                            keep_alive: false,
7825                            ..Default::default()
7826                        },
7827                        WebUiFragmentRoute {
7828                            path: "settings".into(),
7829                            fragment_id: "settings-page".into(),
7830                            exact: true,
7831                            keep_alive: false,
7832                            ..Default::default()
7833                        },
7834                    ],
7835                    keep_alive: false,
7836                    ..Default::default()
7837                })],
7838            },
7839        );
7840
7841        fragments.insert(
7842            "app-shell".to_string(),
7843            FragmentList {
7844                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
7845            },
7846        );
7847        fragments.insert(
7848            "compose-page".to_string(),
7849            FragmentList {
7850                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
7851            },
7852        );
7853        fragments.insert(
7854            "settings-page".to_string(),
7855            FragmentList {
7856                fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
7857            },
7858        );
7859
7860        WebUIProtocol::new(fragments)
7861    }
7862
7863    #[test]
7864    fn test_matched_route_omits_query_attr_from_dom() {
7865        let protocol = make_query_route_protocol();
7866        let state = test_json!({});
7867        let handler = WebUIHandler::new();
7868        let mut writer = TestWriter::new();
7869
7870        handler
7871            .handle(
7872                &protocol,
7873                &state,
7874                &RenderOptions::new("index.html", "/compose"),
7875                &mut writer,
7876            )
7877            .expect("render failed");
7878
7879        let html = writer.get_content();
7880        // query attr is no longer in DOM — it's in the SSR chain JSON instead
7881        assert!(
7882            !html.contains(r#"query="action,to,subject""#),
7883            "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
7884        );
7885    }
7886
7887    #[test]
7888    fn test_nonmatched_route_omits_query_attr_from_dom() {
7889        let protocol = make_query_route_protocol();
7890        let state = test_json!({});
7891        let handler = WebUIHandler::new();
7892        let mut writer = TestWriter::new();
7893
7894        handler
7895            .handle(
7896                &protocol,
7897                &state,
7898                &RenderOptions::new("index.html", "/settings"),
7899                &mut writer,
7900            )
7901            .expect("render failed");
7902
7903        let html = writer.get_content();
7904        // query attr should not be on hidden siblings either
7905        assert!(
7906            !html.contains(r#"query="#),
7907            "hidden route should not have query attr: {html}"
7908        );
7909    }
7910
7911    #[test]
7912    fn test_route_without_query_has_no_query_attr() {
7913        let protocol = make_query_route_protocol();
7914        let state = test_json!({});
7915        let handler = WebUIHandler::new();
7916        let mut writer = TestWriter::new();
7917
7918        handler
7919            .handle(
7920                &protocol,
7921                &state,
7922                &RenderOptions::new("index.html", "/settings"),
7923                &mut writer,
7924            )
7925            .expect("render failed");
7926
7927        let html = writer.get_content();
7928        // Find the settings route element and verify it has no query attr
7929        let settings_idx = html
7930            .find(r#"component="settings-page""#)
7931            .expect("settings route should exist");
7932        let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
7933        assert!(
7934            !settings_tag.contains("query="),
7935            "route without allowed_query should not emit query attr: {settings_tag}"
7936        );
7937    }
7938
7939    // ── Per-render head_inject / body_inject (replaces the byte-scanner
7940    //    InjectingStreamingWriter approach with structural signal-based
7941    //    injection) ───────────────────────────────────────────────────
7942
7943    fn build_head_body_protocol() -> WebUIProtocol {
7944        let mut fragments = HashMap::new();
7945        fragments.insert(
7946            "index.html".to_string(),
7947            FragmentList {
7948                fragments: vec![
7949                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
7950                    WebUIFragment::signal("head_end", true),
7951                    WebUIFragment::raw("</head><body>hello".to_string()),
7952                    WebUIFragment::signal("body_end", true),
7953                    WebUIFragment::raw("</body></html>".to_string()),
7954                ],
7955            },
7956        );
7957        WebUIProtocol::new(fragments)
7958    }
7959
7960    #[test]
7961    fn head_inject_emits_at_head_end_boundary() {
7962        let protocol = build_head_body_protocol();
7963        let state = test_json!({});
7964        let mut writer = TestWriter::new();
7965        let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
7966        handle(&protocol, &state, &opts, &mut writer).unwrap();
7967        let html = writer.get_content();
7968        // The inject must appear immediately before `</head>`.
7969        let inject_idx = html
7970            .find("<link rel=preload>")
7971            .expect("inject HTML missing");
7972        let head_close_idx = html.find("</head>").expect("</head> missing");
7973        assert!(
7974            inject_idx < head_close_idx,
7975            "head_inject must appear before </head>: {html}"
7976        );
7977        // No duplicate.
7978        assert_eq!(html.matches("<link rel=preload>").count(), 1);
7979    }
7980
7981    #[test]
7982    fn body_inject_emits_at_body_end_boundary() {
7983        let protocol = build_head_body_protocol();
7984        let state = test_json!({});
7985        let mut writer = TestWriter::new();
7986        let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
7987        handle(&protocol, &state, &opts, &mut writer).unwrap();
7988        let html = writer.get_content();
7989        let inject_idx = html
7990            .find("<script>lr</script>")
7991            .expect("inject HTML missing");
7992        let body_close_idx = html.find("</body>").expect("</body> missing");
7993        assert!(
7994            inject_idx < body_close_idx,
7995            "body_inject must appear before </body>: {html}"
7996        );
7997        assert_eq!(html.matches("<script>lr</script>").count(), 1);
7998    }
7999
8000    #[test]
8001    fn injects_are_no_op_when_unset() {
8002        let protocol = build_head_body_protocol();
8003        let state = test_json!({});
8004        let mut writer = TestWriter::new();
8005        handle(
8006            &protocol,
8007            &state,
8008            &RenderOptions::new("index.html", "/"),
8009            &mut writer,
8010        )
8011        .unwrap();
8012        let html = writer.get_content();
8013        assert!(!html.contains("<link rel=preload>"));
8014        assert!(!html.contains("<script>lr</script>"));
8015    }
8016
8017    #[test]
8018    fn empty_inject_string_treated_as_unset() {
8019        let protocol = build_head_body_protocol();
8020        let state = test_json!({});
8021        let mut writer = TestWriter::new();
8022        let opts = RenderOptions::new("index.html", "/")
8023            .with_head_inject("")
8024            .with_body_inject("");
8025        handle(&protocol, &state, &opts, &mut writer).unwrap();
8026        // No injection happens — empty strings are normalised to None
8027        // by the builder, so the output is identical to the no-options case.
8028        let html = writer.get_content();
8029        assert!(html.contains("</head>"));
8030        assert!(html.contains("</body>"));
8031    }
8032
8033    #[test]
8034    fn inject_html_is_passed_through_verbatim() {
8035        // The handler does NOT escape the inject string — hosts pass
8036        // raw HTML they trust. This test pins that contract: a `<` in
8037        // the inject is emitted as-is, not encoded as `&lt;`.
8038        let protocol = build_head_body_protocol();
8039        let state = test_json!({});
8040        let mut writer = TestWriter::new();
8041        let opts =
8042            RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
8043        handle(&protocol, &state, &opts, &mut writer).unwrap();
8044        assert!(writer.get_content().contains("<script>var x=1;</script>"));
8045    }
8046
8047    /// Both injects fire and appear at the correct structural
8048    /// positions. Critically, this is robust against `</head>` /
8049    /// `</body>` literals appearing elsewhere in the document — the
8050    /// signal-based emitter cannot mis-fire on byte patterns inside
8051    /// HTML comments, `<iframe srcdoc>`, or inline scripts (which the
8052    /// previous byte-scanner could).
8053    #[test]
8054    fn injects_robust_against_marker_literals_in_content() {
8055        let mut fragments = HashMap::new();
8056        // The body intentionally contains `</body>` and `</head>`
8057        // literals before the actual structural close — these came
8058        // from a (hypothetical) iframe srcdoc or comment.
8059        fragments.insert(
8060            "index.html".to_string(),
8061            FragmentList {
8062                fragments: vec![
8063                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
8064                    WebUIFragment::signal("head_end", true),
8065                    WebUIFragment::raw(
8066                        "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
8067                    ),
8068                    WebUIFragment::signal("body_end", true),
8069                    WebUIFragment::raw("</body></html>".to_string()),
8070                ],
8071            },
8072        );
8073        let protocol = WebUIProtocol::new(fragments);
8074        let state = test_json!({});
8075        let mut writer = TestWriter::new();
8076        let opts = RenderOptions::new("index.html", "/")
8077            .with_head_inject("<HEAD-INJ>")
8078            .with_body_inject("<BODY-INJ>");
8079        handle(&protocol, &state, &opts, &mut writer).unwrap();
8080        let html = writer.get_content();
8081        // The head inject sits between `<title>x</title>` and the
8082        // first `</head>` — the structural one, not the comment one.
8083        let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
8084        let head_close_idx = html.find("</head>").expect("</head> missing");
8085        assert!(head_inj_idx < head_close_idx);
8086        // The body inject sits before the structural `</body>` — NOT
8087        // before the `</body>` literal in the comment (which would
8088        // require the inject to appear inside `<p>hi</p>` somewhere).
8089        let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
8090        // Find the LAST `</body>` (the structural one).
8091        let body_close_idx = html.rfind("</body>").expect("</body> missing");
8092        assert!(
8093            body_inj_idx < body_close_idx,
8094            "body_inject must precede the structural </body>: {html}"
8095        );
8096        // And the comment is preserved verbatim.
8097        assert!(html.contains("<!-- </body> </head> -->"));
8098    }
8099
8100    /// Coverage-14: both `head_inject` AND `body_inject` set in the
8101    /// same render. Each fires at the correct structural boundary and
8102    /// neither leaks into the other's region.
8103    #[test]
8104    fn both_injects_fire_at_correct_boundaries() {
8105        let protocol = build_head_body_protocol();
8106        let state = test_json!({});
8107        let mut writer = TestWriter::new();
8108        let opts = RenderOptions::new("index.html", "/")
8109            .with_head_inject("<META-HEAD>")
8110            .with_body_inject("<SCRIPT-BODY>");
8111        handle(&protocol, &state, &opts, &mut writer).unwrap();
8112        let html = writer.get_content();
8113        let head_idx = html.find("<META-HEAD>").expect("head inject missing");
8114        let head_close = html.find("</head>").expect("</head> missing");
8115        let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
8116        let body_close = html.find("</body>").expect("</body> missing");
8117        assert!(head_idx < head_close, "head_inject before </head>");
8118        assert!(head_close < body_idx, "body_inject after </head>");
8119        assert!(body_idx < body_close, "body_inject before </body>");
8120        assert_eq!(html.matches("<META-HEAD>").count(), 1);
8121        assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
8122    }
8123
8124    /// Coverage-15 / Bug-3 (security defense): a malformed protocol
8125    /// emitting `head_end` and `body_end` more than once must NOT
8126    /// duplicate the host inject HTML. Without the dedup guard,
8127    /// double-emission would amplify Security-2 (a 1 MiB inject ×
8128    /// 1000 duplicate signals = 1 GiB output).
8129    #[test]
8130    fn injects_dedupe_against_duplicate_signals() {
8131        let mut fragments = HashMap::new();
8132        fragments.insert(
8133            "index.html".to_string(),
8134            FragmentList {
8135                fragments: vec![
8136                    WebUIFragment::raw("<html><head>".to_string()),
8137                    WebUIFragment::signal("head_end", true),
8138                    WebUIFragment::signal("head_end", true), // duplicate
8139                    WebUIFragment::signal("head_end", true), // triplicate
8140                    WebUIFragment::raw("</head><body>".to_string()),
8141                    WebUIFragment::signal("body_end", true),
8142                    WebUIFragment::signal("body_end", true), // duplicate
8143                    WebUIFragment::raw("</body></html>".to_string()),
8144                ],
8145            },
8146        );
8147        let protocol = WebUIProtocol::new(fragments);
8148        let state = test_json!({});
8149        let mut writer = TestWriter::new();
8150        let opts = RenderOptions::new("index.html", "/")
8151            .with_head_inject("<HINJ>")
8152            .with_body_inject("<BINJ>");
8153        handle(&protocol, &state, &opts, &mut writer).unwrap();
8154        let html = writer.get_content();
8155        assert_eq!(
8156            html.matches("<HINJ>").count(),
8157            1,
8158            "head_inject must emit exactly once even with duplicate head_end signals"
8159        );
8160        assert_eq!(
8161            html.matches("<BINJ>").count(),
8162            1,
8163            "body_inject must emit exactly once even with duplicate body_end signals"
8164        );
8165    }
8166
8167    /// Coverage-15: a Shadow-DOM / component-only protocol that has NO
8168    /// `<head>` / `<body>` tags must NOT emit the inject (the signals
8169    /// never fire). Verifies the injects are no-ops, not panics.
8170    #[test]
8171    fn injects_no_op_when_no_head_or_body_signals() {
8172        let mut fragments = HashMap::new();
8173        fragments.insert(
8174            "index.html".to_string(),
8175            FragmentList {
8176                fragments: vec![WebUIFragment::raw(
8177                    "<my-component>hi</my-component>".to_string(),
8178                )],
8179            },
8180        );
8181        let protocol = WebUIProtocol::new(fragments);
8182        let state = test_json!({});
8183        let mut writer = TestWriter::new();
8184        let opts = RenderOptions::new("index.html", "/")
8185            .with_head_inject("<HINJ>")
8186            .with_body_inject("<BINJ>");
8187        handle(&protocol, &state, &opts, &mut writer).unwrap();
8188        let html = writer.get_content();
8189        assert!(!html.contains("<HINJ>"), "head_inject must not appear");
8190        assert!(!html.contains("<BINJ>"), "body_inject must not appear");
8191        assert!(html.contains("<my-component>"));
8192    }
8193
8194    /// Coverage-19: the handler's `&self` is shared across threads.
8195    /// Two concurrent renders with different inject values must NOT
8196    /// cross-contaminate (each thread sees only its own inject).
8197    /// Per-render mutable state lives on the `WebUIProcessContext`,
8198    /// which is stack-allocated per call.
8199    #[test]
8200    fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
8201        let protocol = std::sync::Arc::new(build_head_body_protocol());
8202        let state = std::sync::Arc::new(test_json!({}));
8203        let handler = std::sync::Arc::new(WebUIHandler::new());
8204
8205        const N_THREADS: usize = 16;
8206        let mut handles = Vec::with_capacity(N_THREADS);
8207        for tid in 0..N_THREADS {
8208            let h = std::sync::Arc::clone(&handler);
8209            let p = std::sync::Arc::clone(&protocol);
8210            let s = std::sync::Arc::clone(&state);
8211            handles.push(std::thread::spawn(move || {
8212                let head = format!("<HEAD-T{tid}>");
8213                let body = format!("<BODY-T{tid}>");
8214                let mut writer = TestWriter::new();
8215                let opts = RenderOptions::new("index.html", "/")
8216                    .with_head_inject(&head)
8217                    .with_body_inject(&body);
8218                h.handle(&p, &s, &opts, &mut writer).unwrap();
8219                let html = writer.get_content();
8220                // Must contain my own injects exactly once.
8221                assert_eq!(html.matches(&head).count(), 1);
8222                assert_eq!(html.matches(&body).count(), 1);
8223                // Must NOT contain any other thread's inject.
8224                for other in 0..N_THREADS {
8225                    if other == tid {
8226                        continue;
8227                    }
8228                    let other_head = format!("<HEAD-T{other}>");
8229                    let other_body = format!("<BODY-T{other}>");
8230                    assert!(
8231                        !html.contains(&other_head),
8232                        "tid {tid} saw {other}'s head_inject"
8233                    );
8234                    assert!(
8235                        !html.contains(&other_body),
8236                        "tid {tid} saw {other}'s body_inject"
8237                    );
8238                }
8239            }));
8240        }
8241        for h in handles {
8242            h.join().expect("worker panicked");
8243        }
8244    }
8245
8246    /// Coverage-17: a large (1 MiB) head_inject must round-trip
8247    /// correctly without panic, truncation, or excessive overhead.
8248    /// (No size cap is enforced by the handler — the host owns the
8249    /// safety contract; see `with_head_inject` doc comment.)
8250    #[test]
8251    fn large_inject_roundtrips_without_truncation() {
8252        let protocol = build_head_body_protocol();
8253        let state = test_json!({});
8254        let mut writer = TestWriter::new();
8255        let big = "x".repeat(1024 * 1024);
8256        let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
8257        handle(&protocol, &state, &opts, &mut writer).unwrap();
8258        let html = writer.get_content();
8259        assert!(
8260            html.contains(&big),
8261            "large head_inject must be present verbatim ({} bytes)",
8262            big.len()
8263        );
8264        // Sanity: only one copy.
8265        assert_eq!(html.matches(&big).count(), 1);
8266    }
8267
8268    /// `with_nonce("")` must normalize to `None` (no `<meta>` emitted),
8269    /// matching the empty-string semantics of `with_head_inject` /
8270    /// `with_body_inject`. An empty content attribute is browser-
8271    /// ignored noise.
8272    #[test]
8273    fn empty_nonce_treated_as_unset() {
8274        let protocol = build_head_body_protocol();
8275        let state = test_json!({});
8276        let mut writer = TestWriter::new();
8277        let opts = RenderOptions::new("index.html", "/").with_nonce("");
8278        handle(&protocol, &state, &opts, &mut writer).unwrap();
8279        assert!(
8280            !writer.get_content().contains("webui-nonce"),
8281            "empty nonce must not emit <meta name=\"webui-nonce\">"
8282        );
8283    }
8284
8285    /// Regression for the bug Akrosh caught: the `pub` fields on
8286    /// `RenderOptions` let a caller bypass the `with_*` builder
8287    /// normalisation, e.g.:
8288    ///
8289    /// ```ignore
8290    /// RenderOptions { nonce: Some(""), ..RenderOptions::new(e, p) }
8291    /// ```
8292    ///
8293    /// Without defensive normalisation at handler init, this would
8294    /// emit `<script nonce="">` on every inline script. Under a
8295    /// strict `Content-Security-Policy: script-src 'nonce-...'` an
8296    /// empty nonce is a HARD CSP failure that blocks every inline
8297    /// script — a complete inline-script-execution outage.
8298    ///
8299    /// The handler now treats `Some("")` identically to `None` for
8300    /// all three injection points (nonce / head_inject / body_inject)
8301    /// regardless of how the option was populated.
8302    #[test]
8303    fn empty_field_bypass_is_normalised_at_handler_init() {
8304        let protocol = build_head_body_protocol();
8305        let state = test_json!({});
8306
8307        // Bypass the `with_nonce` builder by writing the field directly.
8308        let opts_with_empty_nonce = RenderOptions {
8309            nonce: Some(""),
8310            ..RenderOptions::new("index.html", "/")
8311        };
8312        let mut writer = TestWriter::new();
8313        handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
8314        let html = writer.get_content();
8315        assert!(
8316            !html.contains("webui-nonce"),
8317            "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
8318        );
8319        assert!(
8320            !html.contains("nonce=\"\""),
8321            "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
8322        );
8323
8324        // Same defence for inject fields.
8325        let opts_with_empty_injects = RenderOptions {
8326            head_inject: Some(""),
8327            body_inject: Some(""),
8328            ..RenderOptions::new("index.html", "/")
8329        };
8330        let mut writer = TestWriter::new();
8331        handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
8332        // No assertion needed beyond "doesn't panic and doesn't emit
8333        // empty inject markers" — the head_end / body_end paths must
8334        // treat the empty inject as no-op the same way the builder does.
8335    }
8336
8337    /// Regression for the deep-audit's Bug-6 claim. The for-loop hot-
8338    /// path optimisation (insert key once + `get_mut`-swap value
8339    /// in-place) was suspected of corrupting the outer scope when a
8340    /// nested `<for>` loop reuses the same variable name. This test
8341    /// proves the optimisation is correct under that condition by
8342    /// requiring the outer `item` to be visible before, between, and
8343    /// after the inner loop, with its value preserved across inner
8344    /// iterations.
8345    ///
8346    /// Trace through the optimisation on `outer = [A, B]`,
8347    /// `inner = [X, Y]` with both loops using `item` as the variable:
8348    ///
8349    ///   outer pre-insert "item": Null
8350    ///   iter 1: get_mut → write A
8351    ///     emit "outer:A"
8352    ///     enter inner: saved = remove("item") = Some(A)
8353    ///                  pre-insert "item": Null
8354    ///                  iter 1: write X → emit "inner:X"
8355    ///                  iter 2: write Y → emit "inner:Y"
8356    ///                  restore: insert("item", A)   ← outer's A back
8357    ///     emit "outer:A again"               ← reads A correctly
8358    ///   iter 2: get_mut → write B (overwrites the restored A,
8359    ///                              but that's correct — we're now
8360    ///                              in iter 2 of the outer loop)
8361    ///     emit "outer:B"
8362    ///     enter inner: saved = remove("item") = Some(B), …, restore B
8363    ///     emit "outer:B again"
8364    ///
8365    /// If the audit's claim were correct — that the outer's `get_mut`
8366    /// somehow held a reference past the inner loop and clobbered the
8367    /// restoration — we'd see corrupted values in the "outer:X again"
8368    /// emissions. The assertion below pins the correct sequence.
8369    #[test]
8370    fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
8371        let mut fragments = HashMap::new();
8372        fragments.insert(
8373            "index.html".to_string(),
8374            FragmentList {
8375                fragments: vec![
8376                    WebUIFragment::raw("["),
8377                    WebUIFragment::for_loop("item", "outer", "outer_body"),
8378                    WebUIFragment::raw("]"),
8379                ],
8380            },
8381        );
8382        fragments.insert(
8383            "outer_body".to_string(),
8384            FragmentList {
8385                fragments: vec![
8386                    WebUIFragment::raw("(O="),
8387                    WebUIFragment::signal("item.tag", false),
8388                    WebUIFragment::for_loop("item", "inner", "inner_body"),
8389                    WebUIFragment::raw(",O="),
8390                    WebUIFragment::signal("item.tag", false),
8391                    WebUIFragment::raw(")"),
8392                ],
8393            },
8394        );
8395        fragments.insert(
8396            "inner_body".to_string(),
8397            FragmentList {
8398                fragments: vec![
8399                    WebUIFragment::raw("[I="),
8400                    WebUIFragment::signal("item.tag", false),
8401                    WebUIFragment::raw("]"),
8402                ],
8403            },
8404        );
8405        let protocol = WebUIProtocol::new(fragments);
8406        let state = test_json!({
8407            "outer": [{"tag": "A"}, {"tag": "B"}],
8408            "inner": [{"tag": "X"}, {"tag": "Y"}],
8409        });
8410        let mut writer = TestWriter::new();
8411        handle(
8412            &protocol,
8413            &state,
8414            &RenderOptions::new("index.html", "/"),
8415            &mut writer,
8416        )
8417        .unwrap();
8418        // Expected sequence:
8419        //   outer iter 1 (item=A):
8420        //     emit "(O=A"               ← outer A before inner
8421        //     inner iter 1 (item=X) emit "[I=X]"
8422        //     inner iter 2 (item=Y) emit "[I=Y]"
8423        //     emit ",O=A)"              ← outer A AFTER inner restore
8424        //   outer iter 2 (item=B):
8425        //     emit "(O=B"
8426        //     inner iter 1 (item=X) emit "[I=X]"
8427        //     inner iter 2 (item=Y) emit "[I=Y]"
8428        //     emit ",O=B)"
8429        assert_eq!(
8430            writer.get_content(),
8431            "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
8432            "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
8433        );
8434    }
8435}