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;
15pub(crate) mod streaming;
16
17pub use route_handler::Protocol;
18
19/// Minimal HTML escaper for the 6 XSS-critical characters
20/// (`& < > " ' /`). Returns `Cow::Borrowed` when no escaping is
21/// needed (zero allocation on the happy path), `Cow::Owned` when
22/// any character had to be replaced.
23///
24/// Re-exported here so external callers of `RenderOptions::with_head_inject`
25/// / `with_body_inject` can pre-escape untrusted content with the
26/// same escaper the handler uses internally for SSR text content,
27/// without having to pull in a separate HTML-escape crate.
28pub use html_encode::encode_safe;
29
30use plugin::BootstrapExtensionContext;
31use plugin::HandlerPlugin;
32use plugin::WebUiTemplatePayload;
33use route_matcher::CompiledRouteIndex;
34use serde::ser::SerializeMap;
35use serde::Serialize;
36use serde_json::Value;
37use std::borrow::Cow;
38use std::collections::{HashMap, HashSet};
39use streaming::{
40    consume_streaming_component_root, ensure_no_pending_streaming_root,
41    prepare_generated_streaming_root, record_checkpoint_tag, streaming_template_already_sent,
42    validate_pending_streaming_root, validate_streaming_root_opening, ComponentHostOrigin,
43    StreamingRenderState,
44};
45pub use streaming::{
46    BoundaryId, BoundaryMode, BufferSink, SessionOptions, StreamingResponse, StreamingSession,
47};
48use thiserror::Error;
49use webui_expressions::{evaluate_with_resolver, ExpressionError};
50use webui_protocol::{
51    web_ui_fragment::Fragment, InitialStateStrategy, StateProjectionMode, WebUIFragment,
52    WebUIProtocol,
53};
54use webui_state::find_value_by_dotted_path_ref;
55
56/// Error types for the WebUI handler.
57#[derive(Debug, Error)]
58pub enum HandlerError {
59    #[error("Rendering error: {0}")]
60    Rendering(String),
61
62    #[error("Rendering invariant error: {0}")]
63    Invariant(String),
64
65    #[error("Missing fragment: {0}")]
66    MissingFragment(String),
67
68    #[error("Missing data field: {0}")]
69    MissingData(String),
70
71    #[error("Type error: {0}")]
72    TypeError(String),
73
74    #[error("Protocol error: {0}")]
75    Protocol(#[from] webui_protocol::ProtocolError),
76
77    #[error("Evaluation error: {0}")]
78    Evaluation(String),
79
80    #[error("I/O error: {0}")]
81    Io(#[from] std::io::Error),
82
83    #[error("Writer error: {0}")]
84    Writer(String),
85
86    #[error("Plugin data error: {0}")]
87    PluginData(String),
88
89    /// The HTTP client disconnected before the render completed.
90    ///
91    /// Streaming `ResponseWriter` implementations return this from
92    /// `write()` once their channel/socket is closed, so the handler
93    /// can abort the render rather than do CPU work that has nowhere
94    /// to go. Allocation-free (the variant carries no payload).
95    #[error("client disconnected")]
96    ClientDisconnected,
97
98    /// The streaming writer's flush exceeded its configured deadline.
99    ///
100    /// Indicates a slow/unresponsive consumer (slow-loris client,
101    /// stuck proxy, etc.). The render thread is freed; downstream
102    /// telemetry should distinguish this from `ClientDisconnected`
103    /// so ops can alert on slow-client attacks.
104    #[error("streaming flush timed out")]
105    StreamTimeout,
106
107    /// A streaming-boundary signal was malformed or arrived out of order.
108    ///
109    /// The payload is boxed so this cold, streaming-only variant does not
110    /// widen [`HandlerError`] — and therefore `Result<(), HandlerError>`
111    /// threaded through the entire hot legacy render path — with two inline
112    /// `String`s.
113    #[error("invalid streaming boundary signal `{}`: {}", .0.signal, .0.reason)]
114    StreamingBoundary(Box<StreamingBoundaryError>),
115
116    /// A streaming render ended without its structural `body_end` signal.
117    #[error("streaming render ended before `body_end`; no terminal boundary record was emitted")]
118    MissingStreamingBodyEnd,
119
120    /// Streaming initialization cannot be emitted before document content.
121    #[error(
122        "streaming protocol is missing the required `head_start` signal before `{before}`; \
123         rebuild the protocol with streaming-boundary parser support"
124    )]
125    MissingStreamingHeadStart {
126        /// Structural point that made initialization too late.
127        before: &'static str,
128    },
129
130    /// A malformed protocol emitted streaming initialization more than once.
131    #[error("streaming protocol emitted duplicate `head_start` signals")]
132    DuplicateStreamingHeadStart,
133}
134
135/// Boxed payload for [`HandlerError::StreamingBoundary`].
136///
137/// Kept behind a `Box` so the streaming-validation variant contributes only a
138/// pointer-sized payload to [`HandlerError`], keeping the common
139/// `Result<(), HandlerError>` small on the ordinary render path where these
140/// boundary errors never occur.
141#[derive(Debug)]
142pub struct StreamingBoundaryError {
143    /// The logical structural token (the internal namespace is stripped).
144    pub signal: String,
145    /// Actionable validation failure.
146    pub reason: String,
147}
148
149pub type Result<T> = std::result::Result<T, HandlerError>;
150
151#[cold]
152#[inline(never)]
153fn invalid_fragment_range_error(
154    range: &std::ops::Range<usize>,
155    fragment_count: usize,
156) -> HandlerError {
157    HandlerError::Invariant(format!(
158        "fragment range {}..{} exceeds fragment count {fragment_count}",
159        range.start, range.end
160    ))
161}
162
163/// Interface for writing rendered output
164pub trait ResponseWriter {
165    /// Write content to the output
166    fn write(&mut self, content: &str) -> Result<()>;
167
168    /// Finalize the output
169    fn end(&mut self) -> Result<()>;
170
171    /// Hand buffered bytes to the transport at a committed streaming boundary.
172    ///
173    /// Internal hook used only by the progressive streaming render path so the
174    /// request-local sink can flush its concrete transport without a shared-cell
175    /// borrow or a second virtual dispatch on every write. Ordinary writers keep
176    /// the no-op default; hosts must not override or depend on this — implement
177    /// [`FlushWriter`] and use [`WebUIHandler::render_streaming`] instead.
178    #[doc(hidden)]
179    fn stream_flush(&mut self) -> Result<()> {
180        Ok(())
181    }
182}
183
184/// A response writer that can hand buffered bytes to its transport immediately.
185///
186/// Progressive hydration requires this semantic flush at every committed
187/// boundary. Hosts that cannot provide it must use [`WebUIHandler::render`].
188pub trait FlushWriter: ResponseWriter {
189    /// Hand all currently buffered bytes to the underlying transport.
190    fn flush(&mut self) -> Result<()>;
191}
192
193/// Options controlling how the handler renders a protocol.
194///
195/// The handler performs server-side route matching: matched routes are rendered
196/// visible with content; non-matched routes are rendered hidden and empty.
197pub struct RenderOptions<'a> {
198    /// The fragment ID to start rendering from (e.g., `"index.html"`).
199    pub entry_id: &'a str,
200    /// The URL path to match routes against (e.g., `"/contacts/42"`).
201    pub request_path: &'a str,
202    /// Optional CSP nonce for inline `<script>` tags.
203    /// When set, all inline scripts include `nonce="VALUE"` and a
204    /// `<meta name="webui-nonce">` tag is emitted for the client router.
205    pub nonce: Option<&'a str>,
206    /// Optional HTML to emit immediately before the document's
207    /// `</head>` close. Used for per-request `<link rel="preload">`
208    /// hints, CSP `<meta>` tags beyond the built-in nonce, etc.
209    /// Inserted at the structural `head_end` boundary identified by
210    /// the parser — never matched against a byte pattern, so cannot
211    /// be tricked by `</head>` literals appearing in HTML comments,
212    /// `srcdoc` attributes, or inline scripts.
213    pub head_inject: Option<&'a str>,
214    /// Optional HTML to emit immediately before the document's
215    /// `</body>` close. Used for dev livereload `<script>`, analytics
216    /// snippets, OpenTelemetry trace IDs, etc.
217    /// Same structural-boundary guarantee as [`head_inject`](Self::head_inject).
218    pub body_inject: Option<&'a str>,
219}
220
221impl<'a> RenderOptions<'a> {
222    /// Create render options for the given entry fragment and request path.
223    #[must_use]
224    pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
225        Self {
226            entry_id,
227            request_path,
228            nonce: None,
229            head_inject: None,
230            body_inject: None,
231        }
232    }
233
234    /// Set the CSP nonce for inline scripts. Pass an empty string to
235    /// disable (`None` semantics) — empty `<meta name="webui-nonce"
236    /// content="">` would be browser-ignored noise.
237    #[must_use]
238    pub fn with_nonce(mut self, nonce: &'a str) -> Self {
239        self.nonce = if nonce.is_empty() { None } else { Some(nonce) };
240        self
241    }
242
243    /// Set HTML to emit immediately before `</head>`.
244    /// Pass an empty string to disable (`None` semantics).
245    ///
246    /// # Safety (XSS warning)
247    ///
248    /// The provided HTML is written verbatim — **no HTML escaping is
249    /// performed**. Callers MUST ensure the content is fully trusted
250    /// (typically a `&'static str` or build-time-derived bytes such as
251    /// dev livereload script, image preload `<link>` tags, or A/B test
252    /// markers). Passing user-controlled or attacker-influenced content
253    /// here is a direct cross-site scripting vulnerability. If your
254    /// caller path may include untrusted data, escape with the host's
255    /// HTML escaper (e.g. [`webui_handler::encode_safe`](crate::encode_safe))
256    /// **before** calling this builder.
257    #[must_use]
258    pub fn with_head_inject(mut self, html: &'a str) -> Self {
259        self.head_inject = if html.is_empty() { None } else { Some(html) };
260        self
261    }
262
263    /// Set HTML to emit immediately before `</body>`.
264    /// Pass an empty string to disable (`None` semantics).
265    ///
266    /// # Safety (XSS warning)
267    ///
268    /// Same contract as [`with_head_inject`](Self::with_head_inject):
269    /// the HTML is written verbatim with **no escaping**, so callers
270    /// MUST ensure the content is fully trusted. Untrusted content is
271    /// a direct XSS vector.
272    #[must_use]
273    pub fn with_body_inject(mut self, html: &'a str) -> Self {
274        self.body_inject = if html.is_empty() { None } else { Some(html) };
275        self
276    }
277}
278
279/// The main WebUI handler that processes protocols and renders them.
280///
281/// The handler is stateless: plugin instances are created per-render from
282/// the stored factory function, allowing concurrent renders with `&self`.
283pub struct WebUIHandler {
284    plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
285}
286
287/// Context object for processing WebUI fragments
288pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> {
289    pub(crate) protocol: &'protocol WebUIProtocol,
290    /// Whether this runtime document predates namespaced compiler signals.
291    /// Legacy compatibility is ordinary-render-only; streaming preflight still
292    /// requires the namespaced `head_start` introduced with boundaries.
293    pub(crate) legacy_structural_signals: bool,
294    pub(crate) state: &'state Value,
295    pub(crate) writer: &'output mut dyn ResponseWriter,
296    pub(crate) local_vars: HashMap<String, Value>,
297    /// Accumulates component attribute values between attrStart and the component fragment.
298    pub(crate) component_attrs: HashMap<String, Value>,
299    /// URL path for server-side route matching. Borrowed from
300    /// `RenderOptions<'a>::request_path` — zero-copy.
301    pub(crate) request_path: &'protocol str,
302    /// Base path for resolving relative route paths (`./`).
303    /// Updated as the handler descends into nested matched routes.
304    /// `Cow` keeps the initial `"/"` literal zero-copy; nested-route
305    /// descent owns the recomputed path.
306    pub(crate) route_base: Cow<'protocol, str>,
307    /// Component names visited during rendering (for selective f-template emission
308    /// and CSS module dedup — only the first render of each component emits
309    /// its `<script type="importmap">` data-URI tag).
310    pub(crate) rendered_components: HashSet<String>,
311    /// Per-render plugin instance created from the handler's factory.
312    pub(crate) plugin: Option<Box<dyn HandlerPlugin>>,
313    /// Current position in the route tree for outlet-based rendering.
314    /// Contains the children of the currently matched route fragment.
315    pub(crate) route_children: Vec<webui_protocol::WebUiFragmentRoute>,
316    /// Entry fragment ID — used to compute the initial inventory at head_end.
317    /// Borrowed from `RenderOptions<'a>::entry_id` — zero-copy.
318    pub(crate) entry_id: &'protocol str,
319    /// CSP nonce for inline `<script>` tags (None = no nonce attribute).
320    /// Borrowed from `RenderOptions<'a>::nonce` — zero-copy.
321    pub(crate) nonce: Option<&'protocol str>,
322    /// Component-name → bit-position map built once when the runtime
323    /// [`Protocol`] is created and shared by every render.
324    pub(crate) component_index: &'protocol HashMap<String, u32>,
325    /// HTML emitted at the structural `head_end` boundary (before
326    /// `</head>`), after the built-in nonce/CSS-preload emissions.
327    /// Zero-copy borrow of the caller's `RenderOptions<'a>::head_inject`
328    /// (no per-render clone — saves an allocation when the host passes
329    /// a `&'static str` such as a dev livereload script).
330    pub(crate) head_inject: Option<&'protocol str>,
331    /// HTML emitted at the structural `body_end` boundary (before
332    /// `</body>`), after the built-in template metadata emissions.
333    /// Same zero-copy borrow as [`head_inject`](Self::head_inject).
334    pub(crate) body_inject: Option<&'protocol str>,
335    /// Tracks whether the `head_end` hook has already fired in this
336    /// render. Defends against malformed protocols that emit the
337    /// signal more than once (e.g., a template with multiple `<head>`
338    /// tags) — without this, host-supplied `head_inject` HTML, CSS
339    /// preload `<link>` tags, and the CSP `<meta>` nonce would be
340    /// duplicated, which can be a CSP-bypass / cache-bloat vector.
341    pub(crate) head_end_emitted: bool,
342    /// Tracks whether the `body_end` hook has already fired in this
343    /// render. Defends against malformed protocols emitting the
344    /// signal twice — without this, hydration `<script>` blocks and
345    /// host-supplied `body_inject` would be duplicated.
346    pub(crate) body_end_emitted: bool,
347    /// Immutable authored route patterns compiled when [`Protocol`] is loaded.
348    pub(crate) route_index: &'protocol CompiledRouteIndex,
349    /// Counter for `data-ri` attributes on matched route elements.
350    /// Incremented each time a matched route is rendered, allowing O(1) element
351    /// binding on the client side instead of DOM-walking.
352    pub(crate) route_chain_index: usize,
353    /// Present only for the opt-in progressive streaming render path.
354    pub(crate) streaming: Option<&'output mut StreamingRenderState<'protocol>>,
355    /// Reusable JSON serialization scratch buffer, owned by the render context.
356    /// The bootstrap/field/template helpers borrow it so each render reuses one
357    /// buffer across every serialized field and every streaming checkpoint
358    /// instead of allocating per value. It grows lazily on first serialization
359    /// and is dropped with the context at request end — no per-thread
360    /// high-water buffer is retained between requests.
361    pub(crate) json_scratch: Vec<u8>,
362    /// Small request-local pool of cleared scope maps reused across sibling
363    /// component roots. `process_component` recycles each finished component's
364    /// local/attr map here instead of dropping it, so a sibling reuses the
365    /// bucket capacity rather than reallocating a fresh `HashMap`. Bounded
366    /// ([`SCOPE_POOL_CAP`]) and dropped with the context at request end.
367    pub(crate) scope_pool: Vec<HashMap<String, Value>>,
368}
369
370/// Compiler-owned signal namespace. The leading `}}}` cannot be produced by
371/// authored double- or triple-brace expressions because it closes the binding.
372pub(crate) const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
373
374pub(crate) fn structural_signal_value(
375    signal: &webui_protocol::WebUIFragmentSignal,
376) -> Option<&str> {
377    if !signal.raw {
378        return None;
379    }
380    signal.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
381}
382
383fn legacy_structural_signal_value(signal: &webui_protocol::WebUIFragmentSignal) -> Option<&str> {
384    if !signal.raw {
385        return None;
386    }
387    match signal.value.as_str() {
388        "head_end" | "body_start" | "body_end" => Some(signal.value.as_str()),
389        _ => None,
390    }
391}
392
393/// Maximum scope maps retained in the request-local pool. Small: sibling
394/// component roots rarely nest deeply, so the cap keeps retained capacity bounded.
395const SCOPE_POOL_CAP: usize = 8;
396
397/// Take a cleared scope map from the pool, or a fresh empty one when the pool is
398/// empty. A fresh `HashMap` does not allocate until its first insert.
399fn take_scope_map(pool: &mut Vec<HashMap<String, Value>>) -> HashMap<String, Value> {
400    pool.pop().unwrap_or_default()
401}
402
403/// Return a spent scope map to the pool, clearing it but retaining its bucket
404/// capacity for a sibling root to reuse. Drops the map once the pool is full so
405/// retained memory stays bounded.
406fn recycle_scope_map(pool: &mut Vec<HashMap<String, Value>>, mut map: HashMap<String, Value>) {
407    if pool.len() < SCOPE_POOL_CAP {
408        map.clear();
409        pool.push(map);
410    }
411}
412
413pub(crate) struct WebUiBootstrap<'a> {
414    pub(crate) state: &'a Value,
415    pub(crate) state_selection: StateSelection<'a>,
416    pub(crate) chain: &'a [Value],
417    pub(crate) inventory: &'a str,
418    pub(crate) nonce: Option<&'a str>,
419    pub(crate) css_hrefs: &'a [&'a str],
420    pub(crate) style_specs: &'a [&'a str],
421    pub(crate) templates: &'a [WebUiTemplatePayload<'a>],
422}
423
424/// Get the component attribute name, stripping `:` prefix and converting to camelCase.
425///
426/// Uses `webui_protocol::attrs::attribute_to_camel` which handles irregular
427/// attributes (multi-word ARIA and global HTML attributes like `readonly`,
428/// `tabindex`) via the shared lookup table.
429fn component_attr_name(name: &str) -> String {
430    let stripped = name.strip_prefix(':').unwrap_or(name);
431    webui_protocol::attrs::attribute_to_camel(stripped)
432}
433
434/// Write a usize as decimal digits directly to the writer, avoiding `format!` allocation.
435pub(crate) fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
436    if n == 0 {
437        return writer.write("0");
438    }
439    // Max digits for a 64-bit usize is 20.
440    let mut buf = [0u8; 20];
441    let mut pos = buf.len();
442    while n > 0 {
443        pos -= 1;
444        // n % 10 is always in 0..=9, fits in u8 without truncation.
445        #[allow(clippy::cast_possible_truncation)]
446        let digit = (n % 10) as u8;
447        buf[pos] = b'0' + digit;
448        n /= 10;
449    }
450    // Digits are always valid ASCII/UTF-8.
451    match std::str::from_utf8(&buf[pos..]) {
452        Ok(s) => writer.write(s),
453        Err(_) => writer.write("0"),
454    }
455}
456
457pub(crate) fn write_script_safe_json<T>(
458    writer: &mut dyn ResponseWriter,
459    scratch: &mut Vec<u8>,
460    value: &T,
461) -> Result<()>
462where
463    T: Serialize + ?Sized,
464{
465    // Serialize into the caller's request-local `scratch`. The streaming path
466    // emits one bootstrap envelope per committed boundary and the ordinary
467    // body_end bootstrap serializes several fields, so reusing one buffer across
468    // the render avoids a fresh allocation per value. The buffer grows lazily on
469    // first use (no allocation until serialization needs it) and is dropped with
470    // the render context — capacity is reused within a request but never
471    // retained across requests.
472    scratch.clear();
473    serde_json::to_writer(&mut *scratch, value)
474        .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
475    let json = std::str::from_utf8(scratch)
476        .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
477    write_script_safe_json_str(writer, json)
478}
479
480fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
481    let mut start = 0;
482    while start < json.len() {
483        let rest = &json[start..];
484        let Some(offset) = rest.find("</") else {
485            writer.write(rest)?;
486            return Ok(());
487        };
488
489        if offset > 0 {
490            writer.write(&rest[..offset])?;
491        }
492        writer.write("<\\/")?;
493        start += offset + 2;
494    }
495    Ok(())
496}
497
498fn write_json_field_name(
499    writer: &mut dyn ResponseWriter,
500    wrote_field: &mut bool,
501    name: &str,
502) -> Result<()> {
503    if *wrote_field {
504        writer.write(",")?;
505    }
506    *wrote_field = true;
507    writer.write("\"")?;
508    writer.write(name)?;
509    writer.write("\":")
510}
511
512fn write_json_field<T>(
513    writer: &mut dyn ResponseWriter,
514    scratch: &mut Vec<u8>,
515    wrote_field: &mut bool,
516    name: &str,
517    value: &T,
518) -> Result<()>
519where
520    T: Serialize + ?Sized,
521{
522    write_json_field_name(writer, wrote_field, name)?;
523    write_script_safe_json(writer, scratch, value)
524}
525
526/// Serialize wrapper that projects an SSR state object down to only the
527/// keys present in the build-time hydration allowlist.
528///
529/// This is the runtime half of the projected-hydration design: instead of
530/// serializing the entire application state (potentially megabytes) on every
531/// full-HTML render, only the fields a component actually hydrates are
532/// emitted. The request allowlist conservatively includes every reachable
533/// component's hydration keys so no field a component needs is dropped.
534///
535/// Projection is a payload boundary, not a secrecy boundary. Any key selected
536/// by compiled client metadata is browser-facing, so hosts must never place
537/// secrets in browser render state.
538///
539/// `keys` MUST be sorted and deduplicated. Projection iterates whichever side
540/// is smaller: hydration keys with direct map lookup for wide states, or state
541/// entries with binary-search membership for compact states. Non-object states
542/// carry nothing hydratable and serialize as an empty object.
543struct ProjectedState<'a> {
544    value: &'a Value,
545    keys: &'a [&'a str],
546}
547
548impl Serialize for ProjectedState<'_> {
549    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
550    where
551        S: serde::Serializer,
552    {
553        let Value::Object(map) = self.value else {
554            return serializer.serialize_map(Some(0))?.end();
555        };
556
557        let mut out = serializer.serialize_map(None)?;
558        if self.keys.len() < map.len() {
559            let mut previous = None;
560            for key in self.keys {
561                if previous == Some(*key) {
562                    continue;
563                }
564                previous = Some(*key);
565                if let Some(value) = map.get(*key) {
566                    out.serialize_entry(key, value)?;
567                }
568            }
569        } else {
570            for (key, value) in map {
571                if self
572                    .keys
573                    .binary_search_by(|candidate| candidate.cmp(&key.as_str()))
574                    .is_ok()
575                {
576                    out.serialize_entry(key, value)?;
577                }
578            }
579        }
580        out.end()
581    }
582}
583
584/// Write the SSR `state` into the bootstrap block according to the protocol's
585/// build-time selection and escape it for safe embedding in a `<script>`.
586///
587/// [`ProjectedState`] serializes only the allowlisted keys, so for the typical
588/// payload — a large state with a small hydratable surface — serde ever only
589/// touches the projected subset. Serialization reuses the proven
590/// [`write_script_safe_json`] path (serde's fast `Vec<u8>` target plus a single
591/// SIMD-accelerated `</` escape pass), which matches the pre-projection cost
592/// when every key is hydratable and collapses to a few bytes when it is not.
593/// Buffering the projected bytes and escaping once is measurably faster than
594/// streaming through a per-token `io::Write` adapter, and the projected buffer
595/// is tiny in the common case.
596pub(crate) fn write_selected_state(
597    writer: &mut dyn ResponseWriter,
598    scratch: &mut Vec<u8>,
599    state: &Value,
600    selection: &StateSelection<'_>,
601) -> Result<()> {
602    let keys = match selection {
603        StateSelection::Full => return write_script_safe_json(writer, scratch, state),
604        StateSelection::Keys(keys) => keys.as_slice(),
605        StateSelection::BorrowedKeys(keys) => *keys,
606    };
607    if keys.is_empty() {
608        return writer.write("{}");
609    }
610
611    // Projection membership may use binary search, so a mis-sorted key set
612    // would silently drop hydration keys. The key allowlist is produced sorted +
613    // deduped at build time; this guard makes hand-built protocols that violate
614    // the invariant fail loudly in tests at zero release cost.
615    debug_assert!(
616        keys.windows(2).all(|pair| pair[0] <= pair[1]),
617        "hydration keys must be sorted for binary-search projection"
618    );
619    if let Value::Object(map) = state {
620        let selects_entire_map =
621            keys.len() == map.len() && keys.iter().copied().eq(map.keys().map(String::as_str));
622        if selects_entire_map {
623            return write_script_safe_json(writer, scratch, state);
624        }
625    }
626    write_script_safe_json(writer, scratch, &ProjectedState { value: state, keys })
627}
628
629// Covers the common route surface without trusting protocol-derived counts for
630// an eager allocation; larger key sets grow only as actual keys are visited.
631pub(crate) const INITIAL_KEY_CAPACITY: usize = 16;
632
633/// Request-scoped state selection derived from reachable component metadata.
634pub(crate) enum StateSelection<'a> {
635    /// Preserve the complete state value.
636    Full,
637    /// Project an object to a sorted, deduplicated key allowlist.
638    Keys(Vec<&'a str>),
639    /// Project using request-local scratch owned by the streaming render.
640    BorrowedKeys(&'a [&'a str]),
641}
642
643#[derive(Clone, Copy)]
644enum ComponentStateSurface {
645    Hydration,
646    Navigation,
647}
648
649/// Select initial state for the components reachable on this request path.
650///
651/// Non-WebUI protocols preserve full state without walking component surfaces.
652/// WebUI protocols project exact surfaces, while any unknown surface restores
653/// the full state for correctness.
654pub(crate) fn collect_hydration_state<'a, 'b>(
655    protocol: &'a WebUIProtocol,
656    components: impl IntoIterator<Item = &'b str>,
657) -> StateSelection<'a> {
658    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
659        return StateSelection::Full;
660    }
661    collect_component_state(protocol, components, ComponentStateSurface::Hydration)
662}
663
664pub(crate) fn collect_hydration_state_into<'a, 'b>(
665    protocol: &'a WebUIProtocol,
666    components: impl IntoIterator<Item = &'b str>,
667    keys: &mut Vec<&'a str>,
668) -> bool {
669    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
670        keys.clear();
671        return true;
672    }
673    collect_component_state_into(protocol, components, ComponentStateSurface::Hydration, keys)
674}
675
676/// Select state for client-created components reachable during navigation.
677pub(crate) fn collect_navigation_state<'a, 'b>(
678    protocol: &'a WebUIProtocol,
679    components: impl IntoIterator<Item = &'b str>,
680) -> StateSelection<'a> {
681    collect_component_state(protocol, components, ComponentStateSurface::Navigation)
682}
683
684fn collect_component_state<'a, 'b>(
685    protocol: &'a WebUIProtocol,
686    components: impl IntoIterator<Item = &'b str>,
687    surface: ComponentStateSurface,
688) -> StateSelection<'a> {
689    let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
690    if collect_component_state_into(protocol, components, surface, &mut keys) {
691        StateSelection::Full
692    } else {
693        StateSelection::Keys(keys)
694    }
695}
696
697/// Fill a reusable state-key allowlist. Returns `true` when correctness
698/// requires sending full state instead of the collected keys.
699fn collect_component_state_into<'a, 'b>(
700    protocol: &'a WebUIProtocol,
701    components: impl IntoIterator<Item = &'b str>,
702    surface: ComponentStateSurface,
703    keys: &mut Vec<&'a str>,
704) -> bool {
705    keys.clear();
706    for name in components {
707        let Some(component) = protocol.components.get(name) else {
708            return true;
709        };
710        let (mode, component_keys) = match surface {
711            ComponentStateSurface::Hydration => {
712                (component.hydration_mode, &component.hydration_keys)
713            }
714            ComponentStateSurface::Navigation => (
715                match component.navigation_mode {
716                    Some(mode) => mode,
717                    None if !component.navigation_keys.is_empty() => {
718                        StateProjectionMode::Keys as i32
719                    }
720                    None => return true,
721                },
722                &component.navigation_keys,
723            ),
724        };
725        if mode == StateProjectionMode::All as i32 {
726            return true;
727        }
728        if mode == StateProjectionMode::Keys as i32
729            || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
730        {
731            keys.extend(component_keys.iter().map(String::as_str));
732        } else if mode != StateProjectionMode::None as i32 {
733            return true;
734        }
735    }
736    keys.sort_unstable();
737    keys.dedup();
738    false
739}
740
741pub(crate) fn write_webui_bootstrap(
742    writer: &mut dyn ResponseWriter,
743    scratch: &mut Vec<u8>,
744    bootstrap: WebUiBootstrap<'_>,
745) -> Result<()> {
746    let mut wrote_field = false;
747
748    writer.write("{")?;
749    if !bootstrap.chain.is_empty() {
750        write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?;
751    }
752    if !bootstrap.css_hrefs.is_empty() {
753        write_json_field(
754            writer,
755            scratch,
756            &mut wrote_field,
757            "css",
758            bootstrap.css_hrefs,
759        )?;
760    }
761    write_json_field(
762        writer,
763        scratch,
764        &mut wrote_field,
765        "inventory",
766        bootstrap.inventory,
767    )?;
768    if let Some(nonce) = bootstrap.nonce {
769        write_json_field(writer, scratch, &mut wrote_field, "nonce", nonce)?;
770    }
771    write_json_field_name(writer, &mut wrote_field, "state")?;
772    write_selected_state(writer, scratch, bootstrap.state, &bootstrap.state_selection)?;
773    if !bootstrap.style_specs.is_empty() {
774        write_json_field(
775            writer,
776            scratch,
777            &mut wrote_field,
778            "styles",
779            bootstrap.style_specs,
780        )?;
781    }
782    if bootstrap
783        .templates
784        .iter()
785        .any(|template| !template.template_json.is_empty())
786    {
787        write_json_field_name(writer, &mut wrote_field, "templates")?;
788        write_webui_template_json_map(writer, scratch, bootstrap.templates)?;
789    }
790    writer.write("}")
791}
792
793fn write_webui_data_block(
794    writer: &mut dyn ResponseWriter,
795    scratch: &mut Vec<u8>,
796    bootstrap: WebUiBootstrap<'_>,
797) -> Result<()> {
798    writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
799    if let Some(nonce) = bootstrap.nonce {
800        writer.write(" nonce=\"")?;
801        writer.write(nonce)?;
802        writer.write("\"")?;
803    }
804    writer.write(">")?;
805    write_webui_bootstrap(writer, scratch, bootstrap)?;
806    writer.write("</script>\n")
807}
808
809fn write_webui_template_json_map(
810    writer: &mut dyn ResponseWriter,
811    scratch: &mut Vec<u8>,
812    templates: &[WebUiTemplatePayload<'_>],
813) -> Result<()> {
814    writer.write("{")?;
815    let mut wrote = false;
816    for template in templates {
817        if template.template_json.is_empty() {
818            continue;
819        }
820        if wrote {
821            writer.write(",")?;
822        }
823        wrote = true;
824        write_script_safe_json(writer, scratch, template.tag_name)?;
825        writer.write(":")?;
826        write_script_safe_json_str(writer, template.template_json)?;
827    }
828    writer.write("}")
829}
830
831fn resolve_value_from_sources<'ctx, 'state>(
832    path: &str,
833    local_vars: &'ctx HashMap<String, Value>,
834    state: &'state Value,
835) -> Option<Cow<'ctx, Value>>
836where
837    'state: 'ctx,
838{
839    if let Some(first_part) = path.split('.').next() {
840        if let Some(local_value) = local_vars.get(first_part) {
841            if first_part.len() == path.len() {
842                return Some(Cow::Borrowed(local_value));
843            }
844            let remaining = &path[first_part.len() + 1..];
845            if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
846                return Some(value);
847            }
848        }
849    }
850
851    find_value_by_dotted_path_ref(path, state)
852}
853
854impl WebUIHandler {
855    /// Create a new WebUI handler with no plugin.
856    pub fn new() -> Self {
857        Self {
858            plugin_factory: None,
859        }
860    }
861
862    /// Create a new WebUI handler with a plugin factory.
863    ///
864    /// Each render call creates a fresh plugin instance from the factory,
865    /// enabling concurrent renders with `&self`.
866    pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
867        Self {
868            plugin_factory: Some(factory),
869        }
870    }
871
872    #[cfg(test)]
873    fn handle(
874        &self,
875        document: &WebUIProtocol,
876        state: &Value,
877        options: &RenderOptions<'_>,
878        writer: &mut dyn ResponseWriter,
879    ) -> Result<()> {
880        let protocol = Protocol::new(document.clone());
881        self.render(&protocol, state, options, writer)
882    }
883
884    /// Process a fragment by its ID.
885    ///
886    /// The `context` parameter contains scope-local variables that are accessible during rendering,
887    /// such as loop iteration variables. This is separate from the global `state`.
888    fn process_fragment_id<'data>(
889        &self,
890        fragment_id: &str,
891        context: &mut WebUIProcessContext<'data, '_, '_>,
892    ) -> Result<()> {
893        if let Some(fragment_list) = context.protocol.fragments.get(fragment_id) {
894            self.process_fragment(&fragment_list.fragments, context)
895        } else {
896            Err(HandlerError::MissingFragment(fragment_id.to_string()))
897        }
898    }
899
900    /// Process a vector of fragments.
901    ///
902    /// The `context` maintains scope-specific variables that can be accessed by fragments
903    /// during rendering, while `state` contains the global application state.
904    fn process_fragment<'data>(
905        &self,
906        fragments: &'data [WebUIFragment],
907        context: &mut WebUIProcessContext<'data, '_, '_>,
908    ) -> Result<()> {
909        // Pre-scan: find the best matching route among sibling routes by specificity.
910        // This ensures `/contacts/add` (2 literals) beats `/contacts/:id` (1 literal).
911        // Resolves relative paths (`./`) using the current route_base.
912        let best_route = route_renderer::find_best_route_match(
913            fragments,
914            context.request_path,
915            &context.route_base,
916            context.route_index,
917        );
918        self.process_fragment_range(fragments, 0..fragments.len(), &best_route, context)
919    }
920
921    fn process_fragment_range<'data>(
922        &self,
923        fragments: &'data [WebUIFragment],
924        range: std::ops::Range<usize>,
925        best_route: &Option<(String, route_matcher::RouteMatch)>,
926        context: &mut WebUIProcessContext<'data, '_, '_>,
927    ) -> Result<()> {
928        let Some(selected) = fragments.get(range.clone()) else {
929            return Err(invalid_fragment_range_error(&range, fragments.len()));
930        };
931        for (offset, item) in selected.iter().enumerate() {
932            let index = range.start + offset;
933            if context.streaming.is_some() {
934                validate_pending_streaming_root(item, context)?;
935                validate_streaming_root_opening(&fragments[..index], item)?;
936            }
937            match item.fragment.as_ref() {
938                Some(Fragment::Raw(raw)) => {
939                    context.writer.write(&raw.value)?;
940                }
941                Some(Fragment::Component(component)) => {
942                    self.process_component(
943                        component,
944                        ComponentHostOrigin::ParserProduced,
945                        context,
946                    )?;
947                }
948                Some(Fragment::ForLoop(for_loop)) => {
949                    self.process_for_loop(for_loop, context)?;
950                }
951                Some(Fragment::Signal(signal)) => {
952                    self.process_signal(signal, context)?;
953                }
954                Some(Fragment::IfCond(if_cond)) => {
955                    self.process_if(if_cond, context)?;
956                }
957                Some(Fragment::Attribute(attr)) => {
958                    self.process_attribute(attr, context)?;
959                }
960                Some(Fragment::Plugin(plugin_frag)) => {
961                    if let Some(p) = &mut context.plugin {
962                        p.on_element_data(&plugin_frag.data, context.writer)?;
963                    }
964                }
965                Some(Fragment::Route(route_frag)) => {
966                    self.process_route(route_frag, best_route, context)?;
967                }
968                Some(Fragment::Outlet(_)) => {
969                    self.process_outlet(context)?;
970                }
971                None => {}
972            }
973        }
974        ensure_no_pending_streaming_root(context, "the end of the containing fragment")
975    }
976
977    /// Process an `<outlet />` directive.
978    ///
979    /// Matches children from the currently active route's `children` field
980    /// against the request path, renders the matched child `<webui-route>`
981    /// elements directly at this position (no wrapper element).
982    fn process_outlet(&self, context: &mut WebUIProcessContext) -> Result<()> {
983        let mut children = std::mem::take(&mut context.route_children);
984        if children.is_empty() {
985            return Ok(());
986        }
987
988        // Find the best matching child route
989        let request_segments = route_matcher::split_request_path(context.request_path);
990        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
991        for (idx, child) in children.iter().enumerate() {
992            if let Some(m) = route_matcher::match_route_indexed_with_segments(
993                context.route_index,
994                &child.path,
995                &context.route_base,
996                &request_segments,
997                child.exact,
998            ) {
999                let is_better = best
1000                    .as_ref()
1001                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
1002                if is_better {
1003                    best = Some((idx, m));
1004                }
1005            }
1006        }
1007
1008        // Extract grandchildren from the matched child to avoid cloning.
1009        // We swap out the children vec so we can move it into context without
1010        // cloning, then swap an empty vec back for the sibling rendering pass.
1011        let grandchildren = if let Some((idx, _)) = &best {
1012            std::mem::take(&mut children[*idx].children)
1013        } else {
1014            Vec::new()
1015        };
1016
1017        if let Some((idx, ref rm)) = best {
1018            let matched_child = &children[idx];
1019            let comp = &matched_child.fragment_id;
1020
1021            if !comp.is_empty() {
1022                let saved_route_base = context.route_base.clone();
1023                let saved_route_children = std::mem::take(&mut context.route_children);
1024
1025                if rm.consumed_segments > 0 {
1026                    context.route_base = Cow::Owned(route_matcher::compute_route_base(
1027                        context.request_path,
1028                        rm.consumed_segments,
1029                    ));
1030                }
1031
1032                context.route_children = grandchildren;
1033
1034                // Emit matched <webui-route>
1035                context.writer.write("<webui-route")?;
1036                if !matched_child.path.is_empty() {
1037                    context.writer.write(" path=\"")?;
1038                    context.writer.write(&matched_child.path)?;
1039                    context.writer.write("\"")?;
1040                }
1041                context.writer.write(" component=\"")?;
1042                context.writer.write(comp)?;
1043                context.writer.write("\"")?;
1044                if matched_child.exact {
1045                    context.writer.write(" exact")?;
1046                }
1047                route_renderer::write_route_pending_attrs(context.writer, matched_child)?;
1048                // Emit data-ri for O(1) client-side element binding
1049                let ri = context.route_chain_index;
1050                context.route_chain_index += 1;
1051                context.writer.write(" data-ri=\"")?;
1052                write_usize(context.writer, ri)?;
1053                context.writer.write("\" active>")?;
1054
1055                context.writer.write("<")?;
1056                context.writer.write(comp)?;
1057                if let Some(p) = &context.plugin {
1058                    p.write_route_component_state(context.state, context.writer)?;
1059                }
1060                prepare_generated_streaming_root(comp, context)?;
1061                context.writer.write(">")?;
1062
1063                self.process_component(
1064                    &webui_protocol::WebUIFragmentComponent {
1065                        fragment_id: comp.clone(),
1066                    },
1067                    ComponentHostOrigin::HandlerGenerated,
1068                    context,
1069                )?;
1070
1071                context.writer.write("</")?;
1072                context.writer.write(comp)?;
1073                context.writer.write(">")?;
1074                context.writer.write("</webui-route>")?;
1075
1076                context.route_base = saved_route_base;
1077                context.route_children = saved_route_children;
1078            }
1079        }
1080
1081        // Render non-matched siblings as hidden
1082        for (idx, child) in children.iter().enumerate() {
1083            let is_matched = best.as_ref().is_some_and(|(bi, _)| *bi == idx);
1084            if !is_matched && !child.fragment_id.is_empty() {
1085                context.writer.write("<webui-route")?;
1086                if !child.path.is_empty() {
1087                    context.writer.write(" path=\"")?;
1088                    context.writer.write(&child.path)?;
1089                    context.writer.write("\"")?;
1090                }
1091                context.writer.write(" component=\"")?;
1092                context.writer.write(&child.fragment_id)?;
1093                context.writer.write("\"")?;
1094                if child.exact {
1095                    context.writer.write(" exact")?;
1096                }
1097                route_renderer::write_route_pending_attrs(context.writer, child)?;
1098                context
1099                    .writer
1100                    .write(" style=\"display:none\"></webui-route>")?;
1101            }
1102        }
1103
1104        Ok(())
1105    }
1106
1107    /// Emit a `<script type="importmap">` tag that registers a component's
1108    /// CSS module under its specifier via a `data:text/css,…` URI.
1109    ///
1110    /// Requires Multiple Import Maps (Chrome 133+); each call emits an
1111    /// independent importmap that the browser merges at the document
1112    /// level. The per-render CSP nonce is applied when set (importmap
1113    /// scripts honor `script-src`).
1114    ///
1115    /// Example for `my-comp` with CSS `span{color:blue;}`:
1116    /// `<script type="importmap" nonce="...">{"imports":{"my-comp":"data:text/css,span{color:blue;}"}}</script>`
1117    fn emit_css_module_importmap(
1118        &self,
1119        specifier: &str,
1120        css: &str,
1121        context: &mut WebUIProcessContext,
1122    ) -> Result<()> {
1123        let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
1124        context.writer.write(&tag)?;
1125        Ok(())
1126    }
1127
1128    /// Emit a component's CSS module importmap on its first render
1129    /// (deduped by `rendered_components`) into the component's light DOM,
1130    /// so the browser registers it under the component's specifier
1131    /// before the shadow root template is parsed. See
1132    /// [`Self::emit_css_module_importmap`] for the emitted shape.
1133    ///
1134    /// Only components rendered on the current route get inline
1135    /// definitions; others receive theirs via `templateStyles` during
1136    /// SPA partial navigation.
1137    fn emit_css_module(
1138        &self,
1139        component: &webui_protocol::WebUIFragmentComponent,
1140        context: &mut WebUIProcessContext,
1141    ) -> Result<()> {
1142        let metadata_already_streamed = context.streaming.as_ref().is_some_and(|streaming| {
1143            streaming_template_already_sent(
1144                streaming,
1145                context.component_index,
1146                &component.fragment_id,
1147            )
1148        });
1149        if !metadata_already_streamed
1150            && !context.rendered_components.contains(&component.fragment_id)
1151        {
1152            if let Some(css) = context
1153                .protocol
1154                .components
1155                .get(&component.fragment_id)
1156                .map(|c| c.css.as_str())
1157                .filter(|s| !s.is_empty())
1158            {
1159                self.emit_css_module_importmap(&component.fragment_id, css, context)?;
1160            }
1161        }
1162        Ok(())
1163    }
1164
1165    /// Process a route fragment — renders `<webui-route>` with matched/hidden state.
1166    fn process_route(
1167        &self,
1168        route_frag: &webui_protocol::WebUiFragmentRoute,
1169        best_route: &Option<(String, route_matcher::RouteMatch)>,
1170        context: &mut WebUIProcessContext,
1171    ) -> Result<()> {
1172        let is_matched = best_route
1173            .as_ref()
1174            .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
1175
1176        context.writer.write("<webui-route")?;
1177        if !route_frag.path.is_empty() {
1178            context.writer.write(" path=\"")?;
1179            context.writer.write(&route_frag.path)?;
1180            context.writer.write("\"")?;
1181        }
1182        if !route_frag.fragment_id.is_empty() {
1183            context.writer.write(" component=\"")?;
1184            context.writer.write(&route_frag.fragment_id)?;
1185            context.writer.write("\"")?;
1186        }
1187        if route_frag.exact {
1188            context.writer.write(" exact")?;
1189        }
1190        route_renderer::write_route_pending_attrs(context.writer, route_frag)?;
1191
1192        if is_matched {
1193            // Emit data-ri for O(1) client-side element binding
1194            let ri = context.route_chain_index;
1195            context.route_chain_index += 1;
1196            context.writer.write(" data-ri=\"")?;
1197            write_usize(context.writer, ri)?;
1198            context.writer.write("\" active>")?;
1199
1200            if !route_frag.fragment_id.is_empty() {
1201                let saved_route_base = context.route_base.clone();
1202                let saved_route_children = std::mem::take(&mut context.route_children);
1203                if let Some((_, ref rm)) = best_route {
1204                    context.route_base = Cow::Owned(route_matcher::compute_route_base(
1205                        context.request_path,
1206                        rm.consumed_segments,
1207                    ));
1208                }
1209
1210                context.route_children = route_frag.children.clone();
1211
1212                let comp = webui_protocol::WebUIFragmentComponent {
1213                    fragment_id: route_frag.fragment_id.clone(),
1214                };
1215
1216                context.writer.write("<")?;
1217                context.writer.write(&route_frag.fragment_id)?;
1218                if let Some(p) = &context.plugin {
1219                    p.write_route_component_state(context.state, context.writer)?;
1220                }
1221                prepare_generated_streaming_root(&route_frag.fragment_id, context)?;
1222                context.writer.write(">")?;
1223
1224                self.process_component(&comp, ComponentHostOrigin::HandlerGenerated, context)?;
1225
1226                context.writer.write("</")?;
1227                context.writer.write(&route_frag.fragment_id)?;
1228                context.writer.write(">")?;
1229
1230                context.route_base = saved_route_base;
1231                context.route_children = saved_route_children;
1232            }
1233        } else {
1234            context.writer.write(" style=\"display:none\">")?;
1235        }
1236
1237        context.writer.write("</webui-route>")?;
1238        Ok(())
1239    }
1240
1241    /// Process a component fragment.
1242    fn process_component(
1243        &self,
1244        component: &webui_protocol::WebUIFragmentComponent,
1245        origin: ComponentHostOrigin,
1246        context: &mut WebUIProcessContext,
1247    ) -> Result<()> {
1248        if context.streaming.is_some() {
1249            consume_streaming_component_root(&component.fragment_id, origin, context)?;
1250            // Capture only after root parity succeeds, so malformed protocols
1251            // cannot contribute unmarked hosts to a checkpoint.
1252            record_checkpoint_tag(context, &component.fragment_id);
1253        }
1254
1255        // Emit the component's CSS module importmap into its light DOM and track
1256        // the component as rendered on first encounter only. `rendered_components`
1257        // is a set, so gating the `insert` (and its `String` clone) behind the
1258        // first-encounter check avoids allocating a throwaway `String` for every
1259        // duplicate instance while keeping the set contents identical.
1260        if !context.rendered_components.contains(&component.fragment_id) {
1261            self.emit_css_module(component, context)?;
1262            context
1263                .rendered_components
1264                .insert(component.fragment_id.clone());
1265        }
1266
1267        // Save parent scope. `mem::take` leaves an alloc-free empty map behind.
1268        let saved_local_vars = std::mem::take(&mut context.local_vars);
1269        // The component's accumulated attrs become its local vars; the next
1270        // sibling accumulates into a recycled (capacity-preserving) map from the
1271        // request-local pool instead of a freshly allocated `HashMap`.
1272        let saved_component_attrs = std::mem::replace(
1273            &mut context.component_attrs,
1274            take_scope_map(&mut context.scope_pool),
1275        );
1276        context.local_vars = saved_component_attrs;
1277
1278        if let Some(p) = &mut context.plugin {
1279            p.push_scope();
1280        }
1281
1282        self.process_fragment_id(&component.fragment_id, context)?;
1283
1284        if let Some(p) = &mut context.plugin {
1285            p.pop_scope();
1286        }
1287
1288        // Restore parent scope, recycling this component's local map (its
1289        // accumulated attrs) back into the pool so a sibling reuses its capacity.
1290        let used_locals = std::mem::replace(&mut context.local_vars, saved_local_vars);
1291        recycle_scope_map(&mut context.scope_pool, used_locals);
1292        // The attr accumulator (pulled from the pool above) is cleared for the
1293        // next sibling while retaining its bucket capacity.
1294        context.component_attrs.clear();
1295
1296        Ok(())
1297    }
1298
1299    /// Resolve a dotted path value, checking local variables first, then global state.
1300    fn resolve_value(
1301        &self,
1302        path: &str,
1303        context: &WebUIProcessContext<'_, '_, '_>,
1304    ) -> Option<Value> {
1305        resolve_value_from_sources(path, &context.local_vars, context.state).map(Cow::into_owned)
1306    }
1307
1308    /// Evaluate a condition expression against the current context.
1309    ///
1310    /// Uses a resolver closure that checks local variables first, then falls
1311    /// back to global state — avoiding a full clone of the state tree.
1312    /// Returns false if the condition references a missing value.
1313    fn evaluate_condition(
1314        &self,
1315        condition: &webui_protocol::ConditionExpr,
1316        context: &WebUIProcessContext,
1317    ) -> Result<bool> {
1318        let local_vars = &context.local_vars;
1319        let state = context.state;
1320        match evaluate_with_resolver(condition, |path| {
1321            resolve_value_from_sources(path, local_vars, state)
1322        }) {
1323            Ok(result) => Ok(result),
1324            Err(ExpressionError::MissingValue(_)) => Ok(false),
1325            Err(e) => Err(HandlerError::Evaluation(e.to_string())),
1326        }
1327    }
1328
1329    /// Process a for loop fragment.
1330    ///
1331    /// Creates a new context for each iteration that includes the current loop item.
1332    /// This allows nested templates to access both the loop variable and any parent context.
1333    /// Example: `for item in items` makes "item" available in the loop body.
1334    fn process_for_loop(
1335        &self,
1336        for_loop: &webui_protocol::WebUIFragmentFor,
1337        context: &mut WebUIProcessContext,
1338    ) -> Result<()> {
1339        let collection_name = &for_loop.collection;
1340
1341        // If the collection is missing, treat it as empty (0 iterations) — matches NodeJS behavior.
1342        // Hydration comments are always emitted regardless of collection presence.
1343        let items = match self.resolve_value(collection_name, context) {
1344            Some(Value::Array(arr)) => arr,
1345            Some(_) => {
1346                return Err(HandlerError::TypeError(format!(
1347                    "Collection '{}' is not an array",
1348                    collection_name
1349                )))
1350            }
1351            None => Vec::new(),
1352        };
1353
1354        if let Some(p) = &mut context.plugin {
1355            p.on_for_start(&for_loop.fragment_id, context.writer)?;
1356        }
1357
1358        // Hot-loop optimisation: the loop variable name is `String`-keyed
1359        // in `local_vars`. The naive impl re-inserts (and so re-allocates
1360        // the key) on every iteration — a 1000-item loop pays 2000 String
1361        // clones for the key alone. Instead, we save the outer-scope
1362        // value (if any) ONCE before the loop, install the key ONCE with
1363        // an empty placeholder, then overwrite the value in-place each
1364        // iteration via `get_mut`. Restoration at the end happens once.
1365        let item_name = for_loop.item.as_str();
1366        let saved_value = context.local_vars.remove(item_name);
1367        // Pre-insert the key so per-iteration `get_mut` is infallible.
1368        // Cost: at most one `String::from(item_name)` for the lifetime
1369        // of the loop, regardless of iteration count.
1370        if !items.is_empty() {
1371            context
1372                .local_vars
1373                .insert(item_name.to_string(), Value::Null);
1374        }
1375        for (i, item) in items.into_iter().enumerate() {
1376            if let Some(p) = &mut context.plugin {
1377                p.on_repeat_item_start(i, context.writer)?;
1378                p.push_scope();
1379            }
1380
1381            // O(1) value swap; no key allocation.
1382            if let Some(slot) = context.local_vars.get_mut(item_name) {
1383                *slot = item;
1384            }
1385            self.process_fragment_id(&for_loop.fragment_id, context)?;
1386
1387            if let Some(p) = &mut context.plugin {
1388                p.pop_scope();
1389                p.on_repeat_item_end(i, context.writer)?;
1390            }
1391        }
1392        // Restore outer scope (or remove the placeholder we installed).
1393        match saved_value {
1394            Some(value) => {
1395                context.local_vars.insert(item_name.to_string(), value);
1396            }
1397            None => {
1398                context.local_vars.remove(item_name);
1399            }
1400        }
1401
1402        if let Some(p) = &mut context.plugin {
1403            p.on_for_end(&for_loop.fragment_id, context.writer)?;
1404        }
1405
1406        Ok(())
1407    }
1408
1409    /// Process a signal fragment.
1410    ///
1411    /// Looks up the value in the context first (for local variables), then in the global state.
1412    /// This prioritization allows local variables (like loop items) to override global state.
1413    /// If the value is not found in either scope, an empty string is returned.
1414    #[inline]
1415    fn process_signal<'data>(
1416        &self,
1417        signal: &'data webui_protocol::WebUIFragmentSignal,
1418        context: &mut WebUIProcessContext<'data, '_, '_>,
1419    ) -> Result<()> {
1420        if signal.raw {
1421            self.process_raw_signal(signal, context)
1422        } else {
1423            self.process_state_signal(signal, context)
1424        }
1425    }
1426
1427    #[inline(never)]
1428    fn process_raw_signal<'data>(
1429        &self,
1430        signal: &'data webui_protocol::WebUIFragmentSignal,
1431        context: &mut WebUIProcessContext<'data, '_, '_>,
1432    ) -> Result<()> {
1433        let structural_value = structural_signal_value(signal).or_else(|| {
1434            context
1435                .legacy_structural_signals
1436                .then(|| legacy_structural_signal_value(signal))
1437                .flatten()
1438        });
1439        let Some(structural_value) = structural_value else {
1440            return self.process_state_signal(signal, context);
1441        };
1442
1443        if context.streaming.is_some()
1444            && self.process_streaming_signal(structural_value, context)?
1445        {
1446            return Ok(());
1447        }
1448
1449        // Hook: emit nonce meta and CSS <link> tags before </head>.
1450        // Guarded by `head_end_emitted` so a malformed protocol cannot
1451        // emit nonce/preloads/inject more than once per render.
1452        if structural_value == "head_end" && !context.head_end_emitted {
1453            context.head_end_emitted = true;
1454            if let Some(nonce) = context.nonce {
1455                context
1456                    .writer
1457                    .write("<meta name=\"webui-nonce\" content=\"")?;
1458                context
1459                    .writer
1460                    .write(&crate::html_encode::encode_safe(nonce))?;
1461                context.writer.write("\">")?;
1462            }
1463
1464            // Emit CSS <link> tags in <head> for Link-strategy components.
1465            // For components with a non-empty css_href:
1466            //   Link + Shadow → <link rel="preload"> (stylesheet is in shadow root)
1467            //   Link + Light  → <link rel="stylesheet"> (no shadow root)
1468            //
1469            // Style and Module strategies emit their CSS during component
1470            // rendering (shadow-DOM template / importmap respectively).
1471            let is_link = context.protocol.css_strategy() == webui_protocol::CssStrategy::Link;
1472            let is_shadow = context.protocol.dom_strategy() == webui_protocol::DomStrategy::Shadow;
1473
1474            if is_link {
1475                let (needed_components, _) =
1476                    crate::route_handler::get_needed_components_for_request(
1477                        context.protocol,
1478                        context.entry_id,
1479                        context.request_path,
1480                        "",
1481                        (context.component_index, context.route_index),
1482                    )?;
1483
1484                for name in &needed_components {
1485                    if let Some(href) = context
1486                        .protocol
1487                        .components
1488                        .get(name)
1489                        .map(|c| c.css_href.as_str())
1490                        .filter(|h| !h.is_empty())
1491                    {
1492                        if is_shadow {
1493                            context.writer.write("<link rel=\"preload\" href=\"")?;
1494                            context.writer.write(href)?;
1495                            context
1496                                .writer
1497                                .write("\" as=\"style\" data-webui-ssr-preload=\"style\">")?;
1498                        } else {
1499                            context.writer.write("<link rel=\"stylesheet\" href=\"")?;
1500                            context.writer.write(href)?;
1501                            context.writer.write("\">")?;
1502                        }
1503                    }
1504                }
1505            }
1506
1507            // Compiler-resolved `modulepreload` hints for the shared chunks
1508            // the page's module entries statically import. Those chunks are
1509            // named only inside the entry's own bytes, so without this the
1510            // browser must download and parse the entry before it can even
1511            // discover them.
1512            //
1513            // Emitted after the CSS links on purpose: CSS is render-blocking
1514            // and owns first paint, while these own first interaction, so the
1515            // stylesheet requests go out first. The list arrives pre-ordered
1516            // (largest chunk first) and pre-resolved from the build, so this
1517            // is a straight write with no per-request work.
1518            if !context.protocol.module_preloads.is_empty() {
1519                for href in &context.protocol.module_preloads {
1520                    context
1521                        .writer
1522                        .write("<link rel=\"modulepreload\" href=\"")?;
1523                    context.writer.write(href)?;
1524                    context.writer.write("\">")?;
1525                }
1526            }
1527
1528            // Per-render `head_inject` HTML — image preloads, A/B test
1529            // markers, etc. supplied by the host via RenderOptions.
1530            // Emitted at the structural head_end boundary, after the
1531            // built-in nonce + CSS-link emissions, so host injects
1532            // appear immediately before `</head>`.
1533            if let Some(html) = context.head_inject {
1534                context.writer.write(html)?;
1535            }
1536        }
1537
1538        // Hook: emit component templates and host body_inject before </body>.
1539        // Single guarded block so the dedup flag protects both the
1540        // hydration emission and the host inject from a malformed
1541        // protocol that fires `body_end` more than once per render.
1542        if structural_value == "body_end" && !context.body_end_emitted {
1543            context.body_end_emitted = true;
1544            if context.plugin.is_some() {
1545                // Emit templates for all REACHABLE components on the current route,
1546                // not just those rendered in this SSR pass. Components inside false
1547                // <if> blocks or empty <for> loops are reachable via client-side
1548                // state changes and need their templates available without a server
1549                // round-trip. The graph walker follows conditional and loop branches
1550                // unconditionally, but only descends into the matched route chain —
1551                // components on other routes are delivered via SPA partial navigation.
1552                let reachable = crate::route_handler::collect_reachable_components_for_request(
1553                    context.protocol,
1554                    context.entry_id,
1555                    context.request_path,
1556                    context.route_index,
1557                );
1558                let state_selection =
1559                    collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
1560
1561                // Emit CSS module importmaps for reachable-but-unrendered
1562                // components so the framework can adopt them when an `<if>`
1563                // condition flips true client-side.
1564                for name in &reachable {
1565                    if !context.rendered_components.contains(name) {
1566                        if let Some(css) = context
1567                            .protocol
1568                            .components
1569                            .get(name)
1570                            .map(|c| c.css.as_str())
1571                            .filter(|s| !s.is_empty())
1572                        {
1573                            self.emit_css_module_importmap(name, css, context)?;
1574                        }
1575                    }
1576                }
1577
1578                // Try to collect split WebUI template payloads. If the plugin
1579                // returns None (non-WebUI templates, e.g. FAST), fall back to
1580                // separate emission.
1581                let template_payloads = context
1582                    .plugin
1583                    .as_ref()
1584                    .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
1585
1586                if template_payloads.is_none() {
1587                    // Non-JS templates (FAST plugins) - emit separately
1588                    if let Some(ref p) = context.plugin {
1589                        p.emit_templates(
1590                            context.protocol,
1591                            &reachable,
1592                            context.nonce,
1593                            context.writer,
1594                        )?;
1595                    }
1596                }
1597
1598                // Compute the inventory hex from actually rendered components.
1599                let inventory_hex = crate::route_handler::encode_component_inventory(
1600                    &context.rendered_components,
1601                    context.component_index,
1602                );
1603
1604                // Chain
1605                let chain = crate::route_handler::collect_route_chain(
1606                    context.protocol,
1607                    context.entry_id,
1608                    context.request_path,
1609                    context.route_index,
1610                );
1611                let chain_json: Vec<Value> = chain
1612                    .iter()
1613                    .map(crate::route_handler::RouteChainEntry::to_json)
1614                    .collect();
1615
1616                // CSS hrefs emitted during SSR (Link-strategy components)
1617                let is_link = context.protocol.css_strategy() == webui_protocol::CssStrategy::Link;
1618                let mut css_hrefs: Vec<&str> = Vec::new();
1619                if is_link {
1620                    for name in &reachable {
1621                        if let Some(href) = context
1622                            .protocol
1623                            .components
1624                            .get(name)
1625                            .map(|c| c.css_href.as_str())
1626                            .filter(|h| !h.is_empty())
1627                        {
1628                            css_hrefs.push(href);
1629                        }
1630                    }
1631                }
1632
1633                // Module style specifiers emitted during SSR
1634                let mut style_specs: Vec<&str> = Vec::new();
1635                for name in &reachable {
1636                    if context
1637                        .protocol
1638                        .components
1639                        .get(name)
1640                        .map(|c| !c.css.is_empty())
1641                        .unwrap_or(false)
1642                    {
1643                        style_specs.push(name);
1644                    }
1645                }
1646
1647                let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
1648                let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
1649                write_webui_data_block(
1650                    context.writer,
1651                    &mut context.json_scratch,
1652                    WebUiBootstrap {
1653                        state: context.state,
1654                        state_selection,
1655                        chain: &chain_json,
1656                        inventory: &inventory_hex,
1657                        nonce: context.nonce,
1658                        css_hrefs: &css_hrefs,
1659                        style_specs: &style_specs,
1660                        templates: payloads,
1661                    },
1662                )?;
1663
1664                // Let the active plugin emit any framework-specific executable
1665                // side channel. FAST plugins default to no-op; WebUI installs
1666                // templateFns. Client packages parse #webui-data lazily.
1667                if let Some(ref plugin) = context.plugin {
1668                    plugin.emit_bootstrap_extension(
1669                        BootstrapExtensionContext {
1670                            protocol: context.protocol,
1671                            components: &reachable,
1672                            payloads,
1673                            nonce: context.nonce,
1674                        },
1675                        context.writer,
1676                    )?;
1677                }
1678            }
1679
1680            // Per-render `body_inject` HTML — dev livereload script,
1681            // analytics, etc. supplied by the host via RenderOptions.
1682            // Inside the dedup block but outside the plugin-only
1683            // sub-block above, so it fires regardless of whether a
1684            // hydration plugin is active. Appears immediately before
1685            // `</body>`.
1686            if let Some(html) = context.body_inject {
1687                context.writer.write(html)?;
1688            }
1689        }
1690
1691        // Structural signals are never state lookups. In particular, ordinary
1692        // rendering ignores boundary/root markers byte-for-byte, while authored
1693        // raw bindings with the same visible key remain ordinary state.
1694        Ok(())
1695    }
1696
1697    #[inline]
1698    fn process_state_signal(
1699        &self,
1700        signal: &webui_protocol::WebUIFragmentSignal,
1701        context: &mut WebUIProcessContext,
1702    ) -> Result<()> {
1703        if let Some(p) = &mut context.plugin {
1704            p.on_binding_start(&signal.value, context.writer)?;
1705        }
1706
1707        if let Some(value) = self.resolve_value(&signal.value, context) {
1708            self.write_signal_value(&value, signal.raw, context.writer)?;
1709        }
1710
1711        if let Some(p) = &mut context.plugin {
1712            p.on_binding_end(&signal.value, context.writer)?;
1713        }
1714        Ok(())
1715    }
1716
1717    /// Write a signal value directly to the writer, avoiding intermediate String allocation.
1718    /// For HTML-escaped output, writes the Cow from `encode_safe` directly.
1719    fn write_signal_value(
1720        &self,
1721        value: &Value,
1722        raw: bool,
1723        writer: &mut dyn ResponseWriter,
1724    ) -> Result<()> {
1725        if raw {
1726            match value {
1727                Value::String(s) => writer.write(s),
1728                _ => writer.write(&value.to_string()),
1729            }
1730        } else {
1731            match value {
1732                Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
1733                _ => {
1734                    let s = value.to_string();
1735                    writer.write(&crate::html_encode::encode_safe(&s))
1736                }
1737            }
1738        }
1739    }
1740
1741    /// Process an if condition fragment.
1742    fn process_if(
1743        &self,
1744        if_cond: &webui_protocol::WebUIFragmentIf,
1745        context: &mut WebUIProcessContext,
1746    ) -> Result<()> {
1747        let condition = if_cond
1748            .condition
1749            .as_ref()
1750            .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
1751        let condition_met = self.evaluate_condition(condition, context)?;
1752
1753        if let Some(p) = &mut context.plugin {
1754            p.on_if_start(&if_cond.fragment_id, context.writer)?;
1755        }
1756
1757        if condition_met {
1758            if let Some(p) = &mut context.plugin {
1759                p.push_scope();
1760            }
1761
1762            self.process_fragment_id(&if_cond.fragment_id, context)?;
1763
1764            if let Some(p) = &mut context.plugin {
1765                p.pop_scope();
1766            }
1767        }
1768
1769        if let Some(p) = &mut context.plugin {
1770            p.on_if_end(&if_cond.fragment_id, context.writer)?;
1771        }
1772
1773        Ok(())
1774    }
1775
1776    /// Process an attribute fragment by rendering the attribute name/value pair.
1777    fn process_attribute(
1778        &self,
1779        attr: &webui_protocol::WebUIFragmentAttribute,
1780        context: &mut WebUIProcessContext,
1781    ) -> Result<()> {
1782        // Initialize component attribute accumulator on attrStart
1783        if attr.attr_start {
1784            context.component_attrs = HashMap::new();
1785        }
1786
1787        // Boolean attribute with condition tree
1788        if let Some(condition) = &attr.condition_tree {
1789            let condition_met = self.evaluate_condition(condition, context)?;
1790
1791            if !attr.attr_skip {
1792                let name = component_attr_name(&attr.name);
1793                context
1794                    .component_attrs
1795                    .insert(name, Value::Bool(condition_met));
1796            }
1797
1798            if condition_met {
1799                context.writer.write(" ")?;
1800                context.writer.write(&attr.name)?;
1801            }
1802            return Ok(());
1803        }
1804
1805        // Template attribute (mixed static + dynamic)
1806        if !attr.template.is_empty() {
1807            let raw_value = self.render_template_attr_value(&attr.template, context)?;
1808            let escaped = crate::html_encode::encode_safe(&raw_value);
1809            write_attr(context.writer, &attr.name, &escaped)?;
1810
1811            if !attr.attr_skip {
1812                let name = component_attr_name(&attr.name);
1813                context
1814                    .component_attrs
1815                    .insert(name, Value::String(raw_value));
1816            }
1817            return Ok(());
1818        }
1819
1820        // Simple attribute
1821        if !attr.value.is_empty() {
1822            if attr.raw_value {
1823                // Static attribute — value is the literal string
1824                write_attr(context.writer, &attr.name, &attr.value)?;
1825                if !attr.attr_skip {
1826                    let name = component_attr_name(&attr.name);
1827                    context
1828                        .component_attrs
1829                        .insert(name, Value::String(attr.value.clone()));
1830                }
1831            } else if attr.complex {
1832                // Complex attribute — resolve value, don't render to HTML, store as state
1833                if let Some(value) = self.resolve_value(&attr.value, context) {
1834                    if !attr.attr_skip {
1835                        let stripped = attr.name.strip_prefix(':').unwrap_or(&attr.name);
1836                        let name = component_attr_name(stripped);
1837                        context.component_attrs.insert(name, value);
1838                    }
1839                }
1840            } else {
1841                // Dynamic attribute — resolve and render
1842                let value = self.resolve_value(&attr.value, context);
1843                // Always emit the attribute so FAST hydration markers
1844                // (`data-fe`) match the DOM node structure.
1845                match &value {
1846                    Some(Value::String(s)) => {
1847                        write_attr(
1848                            context.writer,
1849                            &attr.name,
1850                            &crate::html_encode::encode_safe(s),
1851                        )?;
1852                    }
1853                    Some(Value::Null) | None => {
1854                        write_attr(context.writer, &attr.name, "")?;
1855                    }
1856                    Some(other) => {
1857                        let s = other.to_string();
1858                        write_attr(
1859                            context.writer,
1860                            &attr.name,
1861                            &crate::html_encode::encode_safe(&s),
1862                        )?;
1863                    }
1864                }
1865
1866                if !attr.attr_skip {
1867                    let name = component_attr_name(&attr.name);
1868                    context
1869                        .component_attrs
1870                        .insert(name, value.unwrap_or(Value::String(String::new())));
1871                }
1872            }
1873        }
1874
1875        Ok(())
1876    }
1877
1878    /// Render a template attribute's fragments into a raw (unescaped) string.
1879    fn render_template_attr_value(
1880        &self,
1881        template_id: &str,
1882        context: &WebUIProcessContext,
1883    ) -> Result<String> {
1884        let fragments = context
1885            .protocol
1886            .fragments
1887            .get(template_id)
1888            .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
1889        let mut raw_value = String::new();
1890        for frag in &fragments.fragments {
1891            match frag.fragment.as_ref() {
1892                Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
1893                Some(Fragment::Signal(signal)) => {
1894                    if let Some(value) = self.resolve_value(&signal.value, context) {
1895                        match &value {
1896                            Value::String(s) => raw_value.push_str(s),
1897                            _ => raw_value.push_str(&value.to_string()),
1898                        }
1899                    }
1900                }
1901                _ => {}
1902            }
1903        }
1904        Ok(raw_value)
1905    }
1906
1907    /// Render the UI based on the protocol and state.
1908    pub fn render<'a>(
1909        &self,
1910        protocol: &'a Protocol,
1911        state: &'a Value,
1912        options: &RenderOptions<'a>,
1913        writer: &'a mut dyn ResponseWriter,
1914    ) -> Result<()> {
1915        let document = protocol.protocol();
1916        if !document.fragments.contains_key(options.entry_id) {
1917            return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
1918        }
1919        let mut context = WebUIProcessContext {
1920            protocol: document,
1921            legacy_structural_signals: protocol.legacy_structural_signals(),
1922            state,
1923            writer,
1924            local_vars: HashMap::new(),
1925            component_attrs: HashMap::new(),
1926            request_path: options.request_path,
1927            route_base: Cow::Borrowed("/"),
1928            rendered_components: HashSet::new(),
1929            plugin: self.plugin_factory.map(|f| f()),
1930            route_children: Vec::new(),
1931            entry_id: options.entry_id,
1932            // Same defensive normalisation as `handle()`. See the
1933            // doc-comment there for the CSP-outage rationale.
1934            nonce: options.nonce.filter(|s| !s.is_empty()),
1935            head_inject: options.head_inject.filter(|s| !s.is_empty()),
1936            body_inject: options.body_inject.filter(|s| !s.is_empty()),
1937            head_end_emitted: false,
1938            component_index: protocol.component_index(),
1939            body_end_emitted: false,
1940            route_index: protocol.route_index(),
1941            route_chain_index: 0,
1942            streaming: None,
1943            json_scratch: Vec::new(),
1944            scope_pool: Vec::new(),
1945        };
1946
1947        self.process_fragment_id(options.entry_id, &mut context)?;
1948        writer.end()?;
1949
1950        Ok(())
1951    }
1952}
1953
1954impl Default for WebUIHandler {
1955    fn default() -> Self {
1956        Self::new()
1957    }
1958}
1959
1960/// Write ` name="value"` to the writer without allocating a format string.
1961fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
1962    writer.write(" ")?;
1963    writer.write(name)?;
1964    writer.write("=\"")?;
1965    writer.write(value)?;
1966    writer.write("\"")
1967}
1968
1969#[cfg(test)]
1970fn handle(
1971    protocol: &WebUIProtocol,
1972    state: &Value,
1973    options: &RenderOptions<'_>,
1974    writer: &mut dyn ResponseWriter,
1975) -> Result<()> {
1976    let handler = WebUIHandler::new();
1977    handler.handle(protocol, state, options, writer)
1978}
1979
1980#[cfg(test)]
1981mod tests {
1982    use super::*;
1983    use crate::streaming::STREAMING_MARKER;
1984    use std::cell::RefCell;
1985    use std::sync::Arc;
1986    use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
1987    use webui_protocol::{
1988        web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
1989        WebUIFragmentAttribute,
1990    };
1991    use webui_test_utils::test_json;
1992
1993    fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
1994        WebUIFragment::signal(
1995            format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
1996            true,
1997        )
1998    }
1999
2000    // A simple test writer implementation
2001    struct TestWriter {
2002        content: RefCell<String>,
2003        ended: RefCell<bool>,
2004    }
2005
2006    impl TestWriter {
2007        fn new() -> Self {
2008            Self {
2009                content: RefCell::new(String::new()),
2010                ended: RefCell::new(false),
2011            }
2012        }
2013
2014        fn get_content(&self) -> String {
2015            self.content.borrow().clone()
2016        }
2017
2018        fn is_ended(&self) -> bool {
2019            *self.ended.borrow()
2020        }
2021    }
2022
2023    impl ResponseWriter for TestWriter {
2024        fn write(&mut self, content: &str) -> Result<()> {
2025            self.content.borrow_mut().push_str(content);
2026            Ok(())
2027        }
2028
2029        fn end(&mut self) -> Result<()> {
2030            *self.ended.borrow_mut() = true;
2031            Ok(())
2032        }
2033    }
2034
2035    #[test]
2036    fn test_handle_raw() {
2037        // Create a simple protocol
2038        let mut fragments = HashMap::new();
2039        fragments.insert(
2040            "index.html".to_string(),
2041            FragmentList {
2042                fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
2043            },
2044        );
2045
2046        let protocol = WebUIProtocol::new(fragments);
2047        let state = test_json!({});
2048
2049        // Create a test writer
2050        let mut writer = TestWriter::new();
2051
2052        // Handle the protocol
2053        assert!(
2054            handle(
2055                &protocol,
2056                &state,
2057                &RenderOptions::new("index.html", "/"),
2058                &mut writer
2059            )
2060            .is_ok(),
2061            "Failed to handle raw protocol"
2062        );
2063
2064        // Check the output
2065        assert_eq!(writer.get_content(), "Hello, WebUI!");
2066        assert!(writer.is_ended());
2067    }
2068
2069    #[test]
2070    fn test_handle_signal() {
2071        // Create a protocol with a signal
2072        let mut fragments = HashMap::new();
2073        fragments.insert(
2074            "index.html".to_string(),
2075            FragmentList {
2076                fragments: vec![
2077                    WebUIFragment::raw("Hello, "),
2078                    WebUIFragment::signal("name", false),
2079                    WebUIFragment::raw("!"),
2080                ],
2081            },
2082        );
2083
2084        let protocol = WebUIProtocol::new(fragments);
2085        let state = test_json!({"name": "WebUI"});
2086
2087        // Create a test writer
2088        let mut writer = TestWriter::new();
2089
2090        // Handle the protocol
2091        assert!(
2092            handle(
2093                &protocol,
2094                &state,
2095                &RenderOptions::new("index.html", "/"),
2096                &mut writer
2097            )
2098            .is_ok(),
2099            "Failed to handle signal protocol"
2100        );
2101
2102        // Check the output
2103        assert_eq!(writer.get_content(), "Hello, WebUI!");
2104        assert!(writer.is_ended());
2105    }
2106
2107    #[test]
2108    fn test_handle_for_loop() {
2109        // Create a protocol with a for loop
2110        let mut fragments = HashMap::new();
2111        fragments.insert(
2112            "index.html".to_string(),
2113            FragmentList {
2114                fragments: vec![
2115                    WebUIFragment::raw("People: "),
2116                    WebUIFragment::for_loop("person", "people", "person-item"),
2117                ],
2118            },
2119        );
2120
2121        fragments.insert(
2122            "person-item".to_string(),
2123            FragmentList {
2124                fragments: vec![
2125                    WebUIFragment::signal("person.name", false),
2126                    WebUIFragment::raw(", "),
2127                ],
2128            },
2129        );
2130
2131        let protocol = WebUIProtocol::new(fragments);
2132        let state = test_json!({
2133            "people": [
2134                {"name": "Alice"},
2135                {"name": "Bob"},
2136                {"name": "Charlie"}
2137            ]
2138        });
2139
2140        // Create a test writer
2141        let mut writer = TestWriter::new();
2142
2143        // Handle the protocol
2144        assert!(
2145            handle(
2146                &protocol,
2147                &state,
2148                &RenderOptions::new("index.html", "/"),
2149                &mut writer
2150            )
2151            .is_ok(),
2152            "Failed to handle for loop protocol"
2153        );
2154
2155        // Check the output
2156        assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
2157        assert!(writer.is_ended());
2158    }
2159
2160    #[test]
2161    fn test_handle_if_condition() {
2162        // Create a protocol with an if condition
2163        let mut fragments = HashMap::new();
2164        fragments.insert(
2165            "index.html".to_string(),
2166            FragmentList {
2167                fragments: vec![
2168                    WebUIFragment::raw("Status: "),
2169                    WebUIFragment::if_cond(
2170                        webui_protocol::ConditionExpr::identifier("isActive"),
2171                        "active-content",
2172                    ),
2173                    WebUIFragment::raw("End"),
2174                ],
2175            },
2176        );
2177
2178        fragments.insert(
2179            "active-content".to_string(),
2180            FragmentList {
2181                fragments: vec![WebUIFragment::raw("Active")],
2182            },
2183        );
2184
2185        let protocol = WebUIProtocol::new(fragments);
2186
2187        // Test with isActive = true
2188        let state_true = test_json!({"isActive": true});
2189        let mut writer_true = TestWriter::new();
2190        assert!(
2191            handle(
2192                &protocol,
2193                &state_true,
2194                &RenderOptions::new("index.html", "/"),
2195                &mut writer_true
2196            )
2197            .is_ok(),
2198            "Failed to handle if condition (true case)"
2199        );
2200        assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
2201        assert!(writer_true.is_ended());
2202
2203        // Test with isActive = false
2204        let state_false = test_json!({"isActive": false});
2205        let mut writer_false = TestWriter::new();
2206        assert!(
2207            handle(
2208                &protocol,
2209                &state_false,
2210                &RenderOptions::new("index.html", "/"),
2211                &mut writer_false
2212            )
2213            .is_ok(),
2214            "Failed to handle if condition (false case)"
2215        );
2216        assert_eq!(writer_false.get_content(), "Status: End");
2217        assert!(writer_false.is_ended());
2218    }
2219
2220    #[test]
2221    fn test_handle_component() {
2222        // Create a protocol with a component
2223        let mut fragments = HashMap::new();
2224        fragments.insert(
2225            "index.html".to_string(),
2226            FragmentList {
2227                fragments: vec![
2228                    WebUIFragment::raw("Component: "),
2229                    WebUIFragment::component("my-component"),
2230                ],
2231            },
2232        );
2233
2234        fragments.insert(
2235            "my-component".to_string(),
2236            FragmentList {
2237                fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
2238            },
2239        );
2240
2241        let protocol = WebUIProtocol::new(fragments);
2242        let state = test_json!({});
2243
2244        // Create a test writer
2245        let mut writer = TestWriter::new();
2246
2247        // Handle the protocol
2248        assert!(
2249            handle(
2250                &protocol,
2251                &state,
2252                &RenderOptions::new("index.html", "/"),
2253                &mut writer
2254            )
2255            .is_ok(),
2256            "Failed to handle component protocol"
2257        );
2258
2259        // Check the output
2260        assert_eq!(
2261            writer.get_content(),
2262            "Component: <div>Component Content</div>"
2263        );
2264        assert!(writer.is_ended());
2265    }
2266
2267    #[test]
2268    fn test_missing_fragment() {
2269        // Create a protocol with a missing fragment reference
2270        let mut fragments = HashMap::new();
2271        fragments.insert(
2272            "index.html".to_string(),
2273            FragmentList {
2274                fragments: vec![WebUIFragment::component("missing-component")],
2275            },
2276        );
2277
2278        let protocol = WebUIProtocol::new(fragments);
2279        let state = test_json!({});
2280
2281        // Create a test writer
2282        let mut writer = TestWriter::new();
2283
2284        // Handle the protocol
2285        let result = handle(
2286            &protocol,
2287            &state,
2288            &RenderOptions::new("index.html", "/"),
2289            &mut writer,
2290        );
2291
2292        // Expect an error
2293        assert!(result.is_err());
2294        if let Err(HandlerError::MissingFragment(fragment_id)) = result {
2295            assert_eq!(fragment_id, "missing-component");
2296        } else {
2297            panic!("Expected MissingFragment error");
2298        }
2299    }
2300
2301    #[test]
2302    fn test_missing_signal_renders_empty() {
2303        // A signal referencing a field absent from state should render as empty
2304        let mut fragments = HashMap::new();
2305        fragments.insert(
2306            "index.html".to_string(),
2307            FragmentList {
2308                fragments: vec![
2309                    WebUIFragment::raw("Hello, "),
2310                    WebUIFragment::signal("missing_field", false),
2311                    WebUIFragment::raw("!"),
2312                ],
2313            },
2314        );
2315
2316        let protocol = WebUIProtocol::new(fragments);
2317        let state = test_json!({});
2318
2319        let mut writer = TestWriter::new();
2320
2321        assert!(
2322            handle(
2323                &protocol,
2324                &state,
2325                &RenderOptions::new("index.html", "/"),
2326                &mut writer
2327            )
2328            .is_ok(),
2329            "Missing signal should not produce an error"
2330        );
2331
2332        assert_eq!(writer.get_content(), "Hello, !");
2333        assert!(writer.is_ended());
2334    }
2335
2336    // ── Boolean attribute rendering tests ─────────────────────────────
2337
2338    #[test]
2339    fn test_boolean_attr_true() {
2340        let mut fragments = HashMap::new();
2341        fragments.insert(
2342            "index.html".to_string(),
2343            FragmentList {
2344                fragments: vec![
2345                    WebUIFragment::raw("<button"),
2346                    WebUIFragment::attribute_boolean(
2347                        "disabled",
2348                        ConditionExpr::identifier("isDisabled"),
2349                    ),
2350                    WebUIFragment::raw(">Click</button>"),
2351                ],
2352            },
2353        );
2354        let protocol = WebUIProtocol::new(fragments);
2355        let state = test_json!({"isDisabled": true});
2356        let mut writer = TestWriter::new();
2357        handle(
2358            &protocol,
2359            &state,
2360            &RenderOptions::new("index.html", "/"),
2361            &mut writer,
2362        )
2363        .unwrap();
2364        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
2365    }
2366
2367    #[test]
2368    fn test_boolean_attr_false() {
2369        let mut fragments = HashMap::new();
2370        fragments.insert(
2371            "index.html".to_string(),
2372            FragmentList {
2373                fragments: vec![
2374                    WebUIFragment::raw("<button"),
2375                    WebUIFragment::attribute_boolean(
2376                        "disabled",
2377                        ConditionExpr::identifier("isDisabled"),
2378                    ),
2379                    WebUIFragment::raw(">Click</button>"),
2380                ],
2381            },
2382        );
2383        let protocol = WebUIProtocol::new(fragments);
2384        let state = test_json!({"isDisabled": false});
2385        let mut writer = TestWriter::new();
2386        handle(
2387            &protocol,
2388            &state,
2389            &RenderOptions::new("index.html", "/"),
2390            &mut writer,
2391        )
2392        .unwrap();
2393        assert_eq!(writer.get_content(), "<button>Click</button>");
2394    }
2395
2396    #[test]
2397    fn test_boolean_attr_missing() {
2398        let mut fragments = HashMap::new();
2399        fragments.insert(
2400            "index.html".to_string(),
2401            FragmentList {
2402                fragments: vec![
2403                    WebUIFragment::raw("<input type=\"checkbox\""),
2404                    WebUIFragment::attribute_boolean(
2405                        "checked",
2406                        ConditionExpr::identifier("checked"),
2407                    ),
2408                    WebUIFragment::raw(">"),
2409                ],
2410            },
2411        );
2412        let protocol = WebUIProtocol::new(fragments);
2413        let state = test_json!({});
2414        let mut writer = TestWriter::new();
2415        handle(
2416            &protocol,
2417            &state,
2418            &RenderOptions::new("index.html", "/"),
2419            &mut writer,
2420        )
2421        .unwrap();
2422        assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
2423    }
2424
2425    #[test]
2426    fn test_boolean_attr_multiple() {
2427        let mut fragments = HashMap::new();
2428        fragments.insert(
2429            "index.html".to_string(),
2430            FragmentList {
2431                fragments: vec![
2432                    WebUIFragment::raw("<input type=\"checkbox\""),
2433                    WebUIFragment::attribute_boolean(
2434                        "checked",
2435                        ConditionExpr::identifier("checked"),
2436                    ),
2437                    WebUIFragment::attribute_boolean(
2438                        "disabled",
2439                        ConditionExpr::identifier("disabled"),
2440                    ),
2441                    WebUIFragment::raw(">"),
2442                ],
2443            },
2444        );
2445        let protocol = WebUIProtocol::new(fragments);
2446        let state = test_json!({"checked": true, "disabled": false});
2447        let mut writer = TestWriter::new();
2448        handle(
2449            &protocol,
2450            &state,
2451            &RenderOptions::new("index.html", "/"),
2452            &mut writer,
2453        )
2454        .unwrap();
2455        assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
2456    }
2457
2458    // ── Simple attribute rendering tests ──────────────────────────────
2459
2460    #[test]
2461    fn test_attribute_with_value() {
2462        let mut fragments = HashMap::new();
2463        fragments.insert(
2464            "index.html".to_string(),
2465            FragmentList {
2466                fragments: vec![
2467                    WebUIFragment::raw("<input"),
2468                    WebUIFragment::attribute("value", "inputValue"),
2469                    WebUIFragment::raw(">"),
2470                ],
2471            },
2472        );
2473        let protocol = WebUIProtocol::new(fragments);
2474        let state = test_json!({"inputValue": "Hello"});
2475        let mut writer = TestWriter::new();
2476        handle(
2477            &protocol,
2478            &state,
2479            &RenderOptions::new("index.html", "/"),
2480            &mut writer,
2481        )
2482        .unwrap();
2483        assert_eq!(writer.get_content(), "<input value=\"Hello\">");
2484    }
2485
2486    #[test]
2487    fn test_attribute_with_falsy_numeric() {
2488        let mut fragments = HashMap::new();
2489        fragments.insert(
2490            "index.html".to_string(),
2491            FragmentList {
2492                fragments: vec![
2493                    WebUIFragment::raw("<div name=\"test\""),
2494                    WebUIFragment::attribute("handle", "number"),
2495                    WebUIFragment::raw("></div>"),
2496                ],
2497            },
2498        );
2499        let protocol = WebUIProtocol::new(fragments);
2500        let state = test_json!({"number": 0});
2501        let mut writer = TestWriter::new();
2502        handle(
2503            &protocol,
2504            &state,
2505            &RenderOptions::new("index.html", "/"),
2506            &mut writer,
2507        )
2508        .unwrap();
2509        assert_eq!(
2510            writer.get_content(),
2511            "<div name=\"test\" handle=\"0\"></div>"
2512        );
2513    }
2514
2515    // ── Dynamic attribute escaping for non-string JSON types ─────────
2516
2517    #[test]
2518    fn test_attribute_array_value_is_escaped() {
2519        let mut fragments = HashMap::new();
2520        fragments.insert(
2521            "index.html".to_string(),
2522            FragmentList {
2523                fragments: vec![
2524                    WebUIFragment::raw("<a"),
2525                    WebUIFragment::attribute("href", "value"),
2526                    WebUIFragment::raw(">demo</a>"),
2527                ],
2528            },
2529        );
2530        let protocol = WebUIProtocol::new(fragments);
2531        let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
2532        let mut writer = TestWriter::new();
2533        handle(
2534            &protocol,
2535            &state,
2536            &RenderOptions::new("index.html", "/"),
2537            &mut writer,
2538        )
2539        .unwrap();
2540        let content = writer.get_content();
2541        // All inner double quotes must be entity-escaped so that the
2542        // browser never sees a second attribute boundary.
2543        assert!(
2544            content.contains("&quot;"),
2545            "Double quotes inside attribute value must be escaped: {content}"
2546        );
2547        // The href attribute value must be a single contiguous quoted
2548        // string — no extra attributes should appear.
2549        assert_eq!(
2550            content.matches("=\"").count(),
2551            1,
2552            "Only one attribute assignment expected: {content}"
2553        );
2554    }
2555
2556    #[test]
2557    fn test_attribute_object_value_is_escaped() {
2558        let mut fragments = HashMap::new();
2559        fragments.insert(
2560            "index.html".to_string(),
2561            FragmentList {
2562                fragments: vec![
2563                    WebUIFragment::raw("<div"),
2564                    WebUIFragment::attribute("data-cfg", "cfg"),
2565                    WebUIFragment::raw("></div>"),
2566                ],
2567            },
2568        );
2569        let protocol = WebUIProtocol::new(fragments);
2570        let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
2571        let mut writer = TestWriter::new();
2572        handle(
2573            &protocol,
2574            &state,
2575            &RenderOptions::new("index.html", "/"),
2576            &mut writer,
2577        )
2578        .unwrap();
2579        let content = writer.get_content();
2580        assert!(
2581            content.contains("&quot;"),
2582            "Double quotes inside attribute value must be escaped: {content}"
2583        );
2584        assert_eq!(
2585            content.matches("=\"").count(),
2586            1,
2587            "Only one attribute assignment expected: {content}"
2588        );
2589    }
2590
2591    // ── Template attribute rendering tests ────────────────────────────
2592
2593    #[test]
2594    fn test_mixed_attribute_template() {
2595        let mut fragments = HashMap::new();
2596        fragments.insert(
2597            "index.html".to_string(),
2598            FragmentList {
2599                fragments: vec![
2600                    WebUIFragment::raw("<input"),
2601                    WebUIFragment::attribute_template("value", "attr-1"),
2602                    WebUIFragment::raw(">"),
2603                ],
2604            },
2605        );
2606        fragments.insert(
2607            "attr-1".to_string(),
2608            FragmentList {
2609                fragments: vec![
2610                    WebUIFragment::raw("hello "),
2611                    WebUIFragment::signal("item", false),
2612                ],
2613            },
2614        );
2615        let protocol = WebUIProtocol::new(fragments);
2616        let state = test_json!({"item": "world"});
2617        let mut writer = TestWriter::new();
2618        handle(
2619            &protocol,
2620            &state,
2621            &RenderOptions::new("index.html", "/"),
2622            &mut writer,
2623        )
2624        .unwrap();
2625        assert_eq!(writer.get_content(), "<input value=\"hello world\">");
2626    }
2627
2628    // ── Raw signal rendering test ─────────────────────────────────────
2629
2630    #[test]
2631    fn test_raw_signal_not_escaped() {
2632        let mut fragments = HashMap::new();
2633        fragments.insert(
2634            "index.html".to_string(),
2635            FragmentList {
2636                fragments: vec![
2637                    WebUIFragment::signal("html", false),
2638                    WebUIFragment::signal("html", true),
2639                ],
2640            },
2641        );
2642        let protocol = WebUIProtocol::new(fragments);
2643        let state = test_json!({"html": "<strong>hi</strong>"});
2644        let mut writer = TestWriter::new();
2645        handle(
2646            &protocol,
2647            &state,
2648            &RenderOptions::new("index.html", "/"),
2649            &mut writer,
2650        )
2651        .unwrap();
2652        assert_eq!(
2653            writer.get_content(),
2654            "&lt;strong&gt;hi&lt;&#x2F;strong&gt;<strong>hi</strong>"
2655        );
2656    }
2657
2658    // ── Nested for loop tests ─────────────────────────────────────────
2659
2660    #[test]
2661    fn test_nested_for_loop() {
2662        let mut fragments = HashMap::new();
2663        fragments.insert(
2664            "index.html".to_string(),
2665            FragmentList {
2666                fragments: vec![
2667                    WebUIFragment::raw("<div>"),
2668                    WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
2669                    WebUIFragment::raw("</div>"),
2670                ],
2671            },
2672        );
2673        fragments.insert(
2674            "outer".to_string(),
2675            FragmentList {
2676                fragments: vec![
2677                    WebUIFragment::raw("<div>"),
2678                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
2679                    WebUIFragment::raw("</div>"),
2680                ],
2681            },
2682        );
2683        fragments.insert(
2684            "inner".to_string(),
2685            FragmentList {
2686                fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
2687            },
2688        );
2689        let protocol = WebUIProtocol::new(fragments);
2690        let state = test_json!({
2691            "outerItems": [
2692                {"innerItems": [{"name": "A"}, {"name": "B"}]},
2693                {"innerItems": [{"name": "C"}]}
2694            ]
2695        });
2696        let mut writer = TestWriter::new();
2697        handle(
2698            &protocol,
2699            &state,
2700            &RenderOptions::new("index.html", "/"),
2701            &mut writer,
2702        )
2703        .unwrap();
2704        assert_eq!(
2705            writer.get_content(),
2706            "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
2707        );
2708    }
2709
2710    #[test]
2711    fn test_nested_for_with_signals() {
2712        let mut fragments = HashMap::new();
2713        fragments.insert(
2714            "index.html".to_string(),
2715            FragmentList {
2716                fragments: vec![
2717                    WebUIFragment::raw("<div>"),
2718                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
2719                    WebUIFragment::raw("</div>"),
2720                ],
2721            },
2722        );
2723        fragments.insert(
2724            "outerTemplate".to_string(),
2725            FragmentList {
2726                fragments: vec![
2727                    WebUIFragment::raw("<div>"),
2728                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
2729                    WebUIFragment::raw("</div>"),
2730                ],
2731            },
2732        );
2733        fragments.insert(
2734            "innerTemplate".to_string(),
2735            FragmentList {
2736                fragments: vec![
2737                    WebUIFragment::raw("<span>"),
2738                    WebUIFragment::signal("innerItem.name", false),
2739                    WebUIFragment::raw("</span>"),
2740                ],
2741            },
2742        );
2743        let protocol = WebUIProtocol::new(fragments);
2744        let state = test_json!({
2745            "outerItems": [
2746                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
2747                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
2748            ]
2749        });
2750        let mut writer = TestWriter::new();
2751        handle(
2752            &protocol,
2753            &state,
2754            &RenderOptions::new("index.html", "/"),
2755            &mut writer,
2756        )
2757        .unwrap();
2758        assert_eq!(
2759            writer.get_content(),
2760            "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
2761        );
2762    }
2763
2764    #[test]
2765    fn test_nested_for_with_global_state() {
2766        let mut fragments = HashMap::new();
2767        fragments.insert(
2768            "index.html".to_string(),
2769            FragmentList {
2770                fragments: vec![
2771                    WebUIFragment::raw("<div>"),
2772                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
2773                    WebUIFragment::raw("</div>"),
2774                ],
2775            },
2776        );
2777        fragments.insert(
2778            "outerTemplate".to_string(),
2779            FragmentList {
2780                fragments: vec![
2781                    WebUIFragment::raw("<div>"),
2782                    WebUIFragment::signal("globalOuter", false),
2783                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
2784                    WebUIFragment::raw("</div>"),
2785                ],
2786            },
2787        );
2788        fragments.insert(
2789            "innerTemplate".to_string(),
2790            FragmentList {
2791                fragments: vec![
2792                    WebUIFragment::raw("<span>"),
2793                    WebUIFragment::signal("innerItem.name", false),
2794                    WebUIFragment::signal("globalInner", false),
2795                    WebUIFragment::raw("</span>"),
2796                ],
2797            },
2798        );
2799        let protocol = WebUIProtocol::new(fragments);
2800        let state = test_json!({
2801            "globalOuter": "GO",
2802            "globalInner": "GI",
2803            "outerItems": [
2804                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
2805                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
2806            ]
2807        });
2808        let mut writer = TestWriter::new();
2809        handle(
2810            &protocol,
2811            &state,
2812            &RenderOptions::new("index.html", "/"),
2813            &mut writer,
2814        )
2815        .unwrap();
2816        assert_eq!(
2817            writer.get_content(),
2818            "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
2819        );
2820    }
2821
2822    // ── For + If state scoping tests ──────────────────────────────────
2823
2824    #[test]
2825    fn test_if_in_for_uses_local_state() {
2826        let mut fragments = HashMap::new();
2827        fragments.insert(
2828            "index.html".to_string(),
2829            FragmentList {
2830                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
2831            },
2832        );
2833        fragments.insert(
2834            "item-tpl".to_string(),
2835            FragmentList {
2836                fragments: vec![WebUIFragment::if_cond(
2837                    ConditionExpr::identifier("item.visible"),
2838                    "visible-tpl",
2839                )],
2840            },
2841        );
2842        fragments.insert(
2843            "visible-tpl".to_string(),
2844            FragmentList {
2845                fragments: vec![WebUIFragment::signal("item.name", false)],
2846            },
2847        );
2848        let protocol = WebUIProtocol::new(fragments);
2849        let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
2850        let mut writer = TestWriter::new();
2851        handle(
2852            &protocol,
2853            &state,
2854            &RenderOptions::new("index.html", "/"),
2855            &mut writer,
2856        )
2857        .unwrap();
2858        assert_eq!(writer.get_content(), "Show");
2859    }
2860
2861    #[test]
2862    fn test_for_if_local_overrides_global() {
2863        let mut fragments = HashMap::new();
2864        fragments.insert(
2865            "index.html".to_string(),
2866            FragmentList {
2867                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
2868            },
2869        );
2870        fragments.insert(
2871            "item-tpl".to_string(),
2872            FragmentList {
2873                fragments: vec![WebUIFragment::if_cond(
2874                    ConditionExpr::identifier("item.flag"),
2875                    "show-tpl",
2876                )],
2877            },
2878        );
2879        fragments.insert(
2880            "show-tpl".to_string(),
2881            FragmentList {
2882                fragments: vec![WebUIFragment::raw("yes")],
2883            },
2884        );
2885        let protocol = WebUIProtocol::new(fragments);
2886        // Global flag is true, but local item.flag is false for second item
2887        let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
2888        let mut writer = TestWriter::new();
2889        handle(
2890            &protocol,
2891            &state,
2892            &RenderOptions::new("index.html", "/"),
2893            &mut writer,
2894        )
2895        .unwrap();
2896        assert_eq!(writer.get_content(), "yes");
2897    }
2898
2899    // ── Component attribute state tests ───────────────────────────────
2900
2901    #[test]
2902    fn test_component_attr_state_simple() {
2903        let mut fragments = HashMap::new();
2904        fragments.insert(
2905            "index.html".to_string(),
2906            FragmentList {
2907                fragments: vec![
2908                    WebUIFragment::raw("<my-comp"),
2909                    WebUIFragment {
2910                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2911                            WebUIFragmentAttribute {
2912                                name: "title".into(),
2913                                value: "Attribute Title".into(),
2914                                attr_start: true,
2915                                raw_value: true,
2916                                ..Default::default()
2917                            },
2918                        )),
2919                    },
2920                    WebUIFragment::raw(">"),
2921                    WebUIFragment::component("my-comp"),
2922                    WebUIFragment::raw("</my-comp>"),
2923                ],
2924            },
2925        );
2926        fragments.insert(
2927            "my-comp".to_string(),
2928            FragmentList {
2929                fragments: vec![
2930                    WebUIFragment::raw("<span>"),
2931                    WebUIFragment::signal("title", false),
2932                    WebUIFragment::raw("</span>"),
2933                ],
2934            },
2935        );
2936        let protocol = WebUIProtocol::new(fragments);
2937        let state = test_json!({"title": "Global Title"});
2938        let mut writer = TestWriter::new();
2939        handle(
2940            &protocol,
2941            &state,
2942            &RenderOptions::new("index.html", "/"),
2943            &mut writer,
2944        )
2945        .unwrap();
2946        assert_eq!(
2947            writer.get_content(),
2948            "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
2949        );
2950    }
2951
2952    #[test]
2953    fn test_component_attr_state_template() {
2954        let mut fragments = HashMap::new();
2955        fragments.insert(
2956            "index.html".to_string(),
2957            FragmentList {
2958                fragments: vec![
2959                    WebUIFragment::raw("<my-comp"),
2960                    WebUIFragment {
2961                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2962                            WebUIFragmentAttribute {
2963                                name: "title".into(),
2964                                template: "title-attr".into(),
2965                                attr_start: true,
2966                                ..Default::default()
2967                            },
2968                        )),
2969                    },
2970                    WebUIFragment::raw(">"),
2971                    WebUIFragment::component("my-comp"),
2972                    WebUIFragment::raw("</my-comp>"),
2973                ],
2974            },
2975        );
2976        fragments.insert(
2977            "title-attr".to_string(),
2978            FragmentList {
2979                fragments: vec![
2980                    WebUIFragment::raw("hello "),
2981                    WebUIFragment::signal("item", false),
2982                ],
2983            },
2984        );
2985        fragments.insert(
2986            "my-comp".to_string(),
2987            FragmentList {
2988                fragments: vec![
2989                    WebUIFragment::raw("<span>"),
2990                    WebUIFragment::signal("title", false),
2991                    WebUIFragment::raw("</span>"),
2992                ],
2993            },
2994        );
2995        let protocol = WebUIProtocol::new(fragments);
2996        let state = test_json!({"item": "<world>"});
2997        let mut writer = TestWriter::new();
2998        handle(
2999            &protocol,
3000            &state,
3001            &RenderOptions::new("index.html", "/"),
3002            &mut writer,
3003        )
3004        .unwrap();
3005        assert_eq!(
3006            writer.get_content(),
3007            "<my-comp title=\"hello &lt;world&gt;\"><span>hello &lt;world&gt;</span></my-comp>"
3008        );
3009    }
3010
3011    #[test]
3012    fn test_component_attr_camel_case() {
3013        let mut fragments = HashMap::new();
3014        fragments.insert(
3015            "index.html".to_string(),
3016            FragmentList {
3017                fragments: vec![
3018                    WebUIFragment::raw("<my-comp"),
3019                    WebUIFragment {
3020                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3021                            WebUIFragmentAttribute {
3022                                name: "data-title".into(),
3023                                template: "dt-attr".into(),
3024                                attr_start: true,
3025                                ..Default::default()
3026                            },
3027                        )),
3028                    },
3029                    WebUIFragment::raw(">"),
3030                    WebUIFragment::component("my-comp"),
3031                    WebUIFragment::raw("</my-comp>"),
3032                ],
3033            },
3034        );
3035        fragments.insert(
3036            "dt-attr".to_string(),
3037            FragmentList {
3038                fragments: vec![
3039                    WebUIFragment::raw("prefix "),
3040                    WebUIFragment::signal("item", false),
3041                ],
3042            },
3043        );
3044        fragments.insert(
3045            "my-comp".to_string(),
3046            FragmentList {
3047                fragments: vec![
3048                    WebUIFragment::raw("<span>"),
3049                    WebUIFragment::signal("dataTitle", false),
3050                    WebUIFragment::raw("</span>"),
3051                ],
3052            },
3053        );
3054        let protocol = WebUIProtocol::new(fragments);
3055        let state = test_json!({"item": "a&b"});
3056        let mut writer = TestWriter::new();
3057        handle(
3058            &protocol,
3059            &state,
3060            &RenderOptions::new("index.html", "/"),
3061            &mut writer,
3062        )
3063        .unwrap();
3064        assert_eq!(
3065            writer.get_content(),
3066            "<my-comp data-title=\"prefix a&amp;b\"><span>prefix a&amp;b</span></my-comp>"
3067        );
3068    }
3069
3070    #[test]
3071    fn test_component_complex_attr() {
3072        let mut fragments = HashMap::new();
3073        fragments.insert(
3074            "index.html".to_string(),
3075            FragmentList {
3076                fragments: vec![
3077                    WebUIFragment::raw("<my-comp"),
3078                    WebUIFragment {
3079                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3080                            WebUIFragmentAttribute {
3081                                name: ":item".into(),
3082                                value: "complexItem".into(),
3083                                attr_start: true,
3084                                complex: true,
3085                                ..Default::default()
3086                            },
3087                        )),
3088                    },
3089                    WebUIFragment::raw(">"),
3090                    WebUIFragment::component("my-comp"),
3091                    WebUIFragment::raw("</my-comp>"),
3092                ],
3093            },
3094        );
3095        fragments.insert(
3096            "my-comp".to_string(),
3097            FragmentList {
3098                fragments: vec![
3099                    WebUIFragment::raw("<span>"),
3100                    WebUIFragment::signal("item.foo", false),
3101                    WebUIFragment::raw("</span><p>"),
3102                    WebUIFragment::signal("item.bar", false),
3103                    WebUIFragment::raw("</p>"),
3104                ],
3105            },
3106        );
3107        let protocol = WebUIProtocol::new(fragments);
3108        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
3109        let mut writer = TestWriter::new();
3110        handle(
3111            &protocol,
3112            &state,
3113            &RenderOptions::new("index.html", "/"),
3114            &mut writer,
3115        )
3116        .unwrap();
3117        assert_eq!(
3118            writer.get_content(),
3119            "<my-comp><span>1</span><p>true</p></my-comp>"
3120        );
3121    }
3122
3123    #[test]
3124    fn test_component_no_parent_pollution() {
3125        let mut fragments = HashMap::new();
3126        fragments.insert(
3127            "index.html".to_string(),
3128            FragmentList {
3129                fragments: vec![
3130                    WebUIFragment::raw("<parent"),
3131                    WebUIFragment {
3132                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3133                            WebUIFragmentAttribute {
3134                                name: "var".into(),
3135                                value: "var".into(),
3136                                attr_start: true,
3137                                ..Default::default()
3138                            },
3139                        )),
3140                    },
3141                    WebUIFragment::raw(">"),
3142                    WebUIFragment::component("parent"),
3143                    WebUIFragment::raw("</parent>"),
3144                ],
3145            },
3146        );
3147        fragments.insert(
3148            "parent".to_string(),
3149            FragmentList {
3150                fragments: vec![
3151                    WebUIFragment::raw("Before: "),
3152                    WebUIFragment::signal("var", false),
3153                    WebUIFragment::raw("<child foo"),
3154                    WebUIFragment {
3155                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3156                            WebUIFragmentAttribute {
3157                                name: "var".into(),
3158                                value: "replaced".into(),
3159                                raw_value: true,
3160                                ..Default::default()
3161                            },
3162                        )),
3163                    },
3164                    WebUIFragment::raw(">"),
3165                    WebUIFragment::component("child"),
3166                    WebUIFragment::raw("Label</child>After: "),
3167                    WebUIFragment::signal("var", false),
3168                ],
3169            },
3170        );
3171        fragments.insert("child".to_string(), FragmentList { fragments: vec![] });
3172        let protocol = WebUIProtocol::new(fragments);
3173        let state = test_json!({"var": "original"});
3174        let mut writer = TestWriter::new();
3175        handle(
3176            &protocol,
3177            &state,
3178            &RenderOptions::new("index.html", "/"),
3179            &mut writer,
3180        )
3181        .unwrap();
3182        assert_eq!(
3183            writer.get_content(),
3184            "<parent var=\"original\">Before: original<child foo var=\"replaced\">Label</child>After: original</parent>"
3185        );
3186    }
3187
3188    #[test]
3189    fn test_component_boolean_attr_state() {
3190        let mut fragments = HashMap::new();
3191        fragments.insert(
3192            "index.html".to_string(),
3193            FragmentList {
3194                fragments: vec![
3195                    WebUIFragment::raw("<my-comp"),
3196                    WebUIFragment {
3197                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3198                            WebUIFragmentAttribute {
3199                                name: "disabled".into(),
3200                                attr_start: true,
3201                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
3202                                ..Default::default()
3203                            },
3204                        )),
3205                    },
3206                    WebUIFragment::raw(">"),
3207                    WebUIFragment::component("my-comp"),
3208                    WebUIFragment::raw("</my-comp>"),
3209                ],
3210            },
3211        );
3212        fragments.insert(
3213            "my-comp".to_string(),
3214            FragmentList {
3215                fragments: vec![WebUIFragment::if_cond(
3216                    ConditionExpr::identifier("disabled"),
3217                    "show",
3218                )],
3219            },
3220        );
3221        fragments.insert(
3222            "show".to_string(),
3223            FragmentList {
3224                fragments: vec![WebUIFragment::raw("disabled!")],
3225            },
3226        );
3227        let protocol = WebUIProtocol::new(fragments);
3228        let state = test_json!({"isDisabled": true});
3229        let mut writer = TestWriter::new();
3230        handle(
3231            &protocol,
3232            &state,
3233            &RenderOptions::new("index.html", "/"),
3234            &mut writer,
3235        )
3236        .unwrap();
3237        assert_eq!(
3238            writer.get_content(),
3239            "<my-comp disabled>disabled!</my-comp>"
3240        );
3241    }
3242
3243    // ===== HTML Escape Tests (ported from utils.test.js escapeHtml) =====
3244
3245    /// Helper: render a signal value through the handler and return the escaped output.
3246    fn render_signal(value: &str) -> String {
3247        let mut fragments = HashMap::new();
3248        fragments.insert(
3249            "index.html".to_string(),
3250            FragmentList {
3251                fragments: vec![WebUIFragment::signal("v", false)],
3252            },
3253        );
3254        let protocol = WebUIProtocol::new(fragments);
3255        let state = test_json!({"v": value});
3256        let mut writer = TestWriter::new();
3257        handle(
3258            &protocol,
3259            &state,
3260            &RenderOptions::new("index.html", "/"),
3261            &mut writer,
3262        )
3263        .unwrap();
3264        writer.get_content()
3265    }
3266
3267    #[test]
3268    fn test_escape_ampersand() {
3269        assert_eq!(render_signal("&"), "&amp;");
3270    }
3271
3272    #[test]
3273    fn test_escape_less_than() {
3274        assert_eq!(render_signal("<"), "&lt;");
3275    }
3276
3277    #[test]
3278    fn test_escape_greater_than() {
3279        assert_eq!(render_signal(">"), "&gt;");
3280    }
3281
3282    #[test]
3283    fn test_escape_double_quote() {
3284        assert_eq!(render_signal("\""), "&quot;");
3285    }
3286
3287    #[test]
3288    fn test_escape_single_quote() {
3289        // encode_safe escapes ' as &#x27;
3290        let result = render_signal("'");
3291        assert_eq!(
3292            result, "&#x27;",
3293            "Expected &#x27; for single quote, got: {result}"
3294        );
3295    }
3296
3297    #[test]
3298    fn test_escape_multiple_special_chars() {
3299        let result = render_signal("<script>alert('xss');</script>");
3300        assert!(
3301            result.contains("&lt;") && result.contains("&gt;"),
3302            "Expected escaped HTML, got: {}",
3303            result
3304        );
3305        assert!(
3306            !result.contains("<script>"),
3307            "Should not contain raw <script> tag"
3308        );
3309    }
3310
3311    #[test]
3312    fn test_escape_no_special_chars() {
3313        assert_eq!(render_signal("Hello World"), "Hello World");
3314    }
3315
3316    #[test]
3317    fn test_escape_empty_string() {
3318        assert_eq!(render_signal(""), "");
3319    }
3320
3321    #[test]
3322    fn test_escape_special_at_beginning() {
3323        let result = render_signal("<Hello");
3324        assert!(
3325            result.starts_with("&lt;"),
3326            "Expected &lt; at start, got: {}",
3327            result
3328        );
3329    }
3330
3331    #[test]
3332    fn test_escape_special_at_end() {
3333        let result = render_signal("Hello>");
3334        assert!(
3335            result.ends_with("&gt;"),
3336            "Expected &gt; at end, got: {}",
3337            result
3338        );
3339    }
3340
3341    #[test]
3342    fn test_escape_special_in_middle() {
3343        let result = render_signal("Hel&lo");
3344        assert!(
3345            result.contains("&amp;"),
3346            "Expected &amp; in middle, got: {}",
3347            result
3348        );
3349    }
3350
3351    // ── GROUP 5: Boolean Attribute Edge Cases ─────────────────────────
3352
3353    #[test]
3354    fn test_boolean_attr_truthy_values() {
3355        // checked: 1
3356        {
3357            let mut fragments = HashMap::new();
3358            fragments.insert(
3359                "index.html".to_string(),
3360                FragmentList {
3361                    fragments: vec![
3362                        WebUIFragment::raw("<input"),
3363                        WebUIFragment::attribute_boolean(
3364                            "checked",
3365                            ConditionExpr::identifier("checked"),
3366                        ),
3367                        WebUIFragment::raw(">"),
3368                    ],
3369                },
3370            );
3371            let protocol = WebUIProtocol::new(fragments);
3372            let state = test_json!({"checked": 1});
3373            let mut writer = TestWriter::new();
3374            handle(
3375                &protocol,
3376                &state,
3377                &RenderOptions::new("index.html", "/"),
3378                &mut writer,
3379            )
3380            .unwrap();
3381            assert_eq!(writer.get_content(), "<input checked>");
3382        }
3383        // checked: "yes"
3384        {
3385            let mut fragments = HashMap::new();
3386            fragments.insert(
3387                "index.html".to_string(),
3388                FragmentList {
3389                    fragments: vec![
3390                        WebUIFragment::raw("<input"),
3391                        WebUIFragment::attribute_boolean(
3392                            "checked",
3393                            ConditionExpr::identifier("checked"),
3394                        ),
3395                        WebUIFragment::raw(">"),
3396                    ],
3397                },
3398            );
3399            let protocol = WebUIProtocol::new(fragments);
3400            let state = test_json!({"checked": "yes"});
3401            let mut writer = TestWriter::new();
3402            handle(
3403                &protocol,
3404                &state,
3405                &RenderOptions::new("index.html", "/"),
3406                &mut writer,
3407            )
3408            .unwrap();
3409            assert_eq!(writer.get_content(), "<input checked>");
3410        }
3411        // checked: {} (empty object is truthy)
3412        {
3413            let mut fragments = HashMap::new();
3414            fragments.insert(
3415                "index.html".to_string(),
3416                FragmentList {
3417                    fragments: vec![
3418                        WebUIFragment::raw("<input"),
3419                        WebUIFragment::attribute_boolean(
3420                            "checked",
3421                            ConditionExpr::identifier("checked"),
3422                        ),
3423                        WebUIFragment::raw(">"),
3424                    ],
3425                },
3426            );
3427            let protocol = WebUIProtocol::new(fragments);
3428            let state = test_json!({"checked": {}});
3429            let mut writer = TestWriter::new();
3430            handle(
3431                &protocol,
3432                &state,
3433                &RenderOptions::new("index.html", "/"),
3434                &mut writer,
3435            )
3436            .unwrap();
3437            // Empty object is falsy in this expression evaluator
3438            assert_eq!(writer.get_content(), "<input>");
3439        }
3440        // checked: "false" (string "false" is truthy)
3441        {
3442            let mut fragments = HashMap::new();
3443            fragments.insert(
3444                "index.html".to_string(),
3445                FragmentList {
3446                    fragments: vec![
3447                        WebUIFragment::raw("<input"),
3448                        WebUIFragment::attribute_boolean(
3449                            "checked",
3450                            ConditionExpr::identifier("checked"),
3451                        ),
3452                        WebUIFragment::raw(">"),
3453                    ],
3454                },
3455            );
3456            let protocol = WebUIProtocol::new(fragments);
3457            let state = test_json!({"checked": "false"});
3458            let mut writer = TestWriter::new();
3459            handle(
3460                &protocol,
3461                &state,
3462                &RenderOptions::new("index.html", "/"),
3463                &mut writer,
3464            )
3465            .unwrap();
3466            assert_eq!(writer.get_content(), "<input checked>");
3467        }
3468    }
3469
3470    #[test]
3471    fn test_boolean_attr_falsy_values() {
3472        // checked: 0
3473        {
3474            let mut fragments = HashMap::new();
3475            fragments.insert(
3476                "index.html".to_string(),
3477                FragmentList {
3478                    fragments: vec![
3479                        WebUIFragment::raw("<input"),
3480                        WebUIFragment::attribute_boolean(
3481                            "checked",
3482                            ConditionExpr::identifier("checked"),
3483                        ),
3484                        WebUIFragment::raw(">"),
3485                    ],
3486                },
3487            );
3488            let protocol = WebUIProtocol::new(fragments);
3489            let state = test_json!({"checked": 0});
3490            let mut writer = TestWriter::new();
3491            handle(
3492                &protocol,
3493                &state,
3494                &RenderOptions::new("index.html", "/"),
3495                &mut writer,
3496            )
3497            .unwrap();
3498            assert_eq!(writer.get_content(), "<input>");
3499        }
3500        // checked: ""
3501        {
3502            let mut fragments = HashMap::new();
3503            fragments.insert(
3504                "index.html".to_string(),
3505                FragmentList {
3506                    fragments: vec![
3507                        WebUIFragment::raw("<input"),
3508                        WebUIFragment::attribute_boolean(
3509                            "checked",
3510                            ConditionExpr::identifier("checked"),
3511                        ),
3512                        WebUIFragment::raw(">"),
3513                    ],
3514                },
3515            );
3516            let protocol = WebUIProtocol::new(fragments);
3517            let state = test_json!({"checked": ""});
3518            let mut writer = TestWriter::new();
3519            handle(
3520                &protocol,
3521                &state,
3522                &RenderOptions::new("index.html", "/"),
3523                &mut writer,
3524            )
3525            .unwrap();
3526            assert_eq!(writer.get_content(), "<input>");
3527        }
3528        // checked: false
3529        {
3530            let mut fragments = HashMap::new();
3531            fragments.insert(
3532                "index.html".to_string(),
3533                FragmentList {
3534                    fragments: vec![
3535                        WebUIFragment::raw("<input"),
3536                        WebUIFragment::attribute_boolean(
3537                            "checked",
3538                            ConditionExpr::identifier("checked"),
3539                        ),
3540                        WebUIFragment::raw(">"),
3541                    ],
3542                },
3543            );
3544            let protocol = WebUIProtocol::new(fragments);
3545            let state = test_json!({"checked": false});
3546            let mut writer = TestWriter::new();
3547            handle(
3548                &protocol,
3549                &state,
3550                &RenderOptions::new("index.html", "/"),
3551                &mut writer,
3552            )
3553            .unwrap();
3554            assert_eq!(writer.get_content(), "<input>");
3555        }
3556        // no checked key at all
3557        {
3558            let mut fragments = HashMap::new();
3559            fragments.insert(
3560                "index.html".to_string(),
3561                FragmentList {
3562                    fragments: vec![
3563                        WebUIFragment::raw("<input"),
3564                        WebUIFragment::attribute_boolean(
3565                            "checked",
3566                            ConditionExpr::identifier("checked"),
3567                        ),
3568                        WebUIFragment::raw(">"),
3569                    ],
3570                },
3571            );
3572            let protocol = WebUIProtocol::new(fragments);
3573            let state = test_json!({});
3574            let mut writer = TestWriter::new();
3575            handle(
3576                &protocol,
3577                &state,
3578                &RenderOptions::new("index.html", "/"),
3579                &mut writer,
3580            )
3581            .unwrap();
3582            assert_eq!(writer.get_content(), "<input>");
3583        }
3584    }
3585
3586    #[test]
3587    fn test_boolean_attr_expression_true() {
3588        let mut fragments = HashMap::new();
3589        fragments.insert(
3590            "index.html".to_string(),
3591            FragmentList {
3592                fragments: vec![
3593                    WebUIFragment::raw("<button"),
3594                    WebUIFragment::attribute_boolean(
3595                        "disabled",
3596                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
3597                    ),
3598                    WebUIFragment::raw(">Click</button>"),
3599                ],
3600            },
3601        );
3602        let protocol = WebUIProtocol::new(fragments);
3603        let state = test_json!({"itemCount": 5});
3604        let mut writer = TestWriter::new();
3605        handle(
3606            &protocol,
3607            &state,
3608            &RenderOptions::new("index.html", "/"),
3609            &mut writer,
3610        )
3611        .unwrap();
3612        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
3613    }
3614
3615    #[test]
3616    fn test_boolean_attr_expression_false() {
3617        let mut fragments = HashMap::new();
3618        fragments.insert(
3619            "index.html".to_string(),
3620            FragmentList {
3621                fragments: vec![
3622                    WebUIFragment::raw("<button"),
3623                    WebUIFragment::attribute_boolean(
3624                        "disabled",
3625                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
3626                    ),
3627                    WebUIFragment::raw(">Click</button>"),
3628                ],
3629            },
3630        );
3631        let protocol = WebUIProtocol::new(fragments);
3632        let state = test_json!({"itemCount": 3});
3633        let mut writer = TestWriter::new();
3634        handle(
3635            &protocol,
3636            &state,
3637            &RenderOptions::new("index.html", "/"),
3638            &mut writer,
3639        )
3640        .unwrap();
3641        assert_eq!(writer.get_content(), "<button>Click</button>");
3642    }
3643
3644    // ── GROUP 6: Mixed Attributes ─────────────────────────────────────
3645
3646    #[test]
3647    fn test_nested_component_attr_capture() {
3648        let mut fragments = HashMap::new();
3649        fragments.insert(
3650            "index.html".to_string(),
3651            FragmentList {
3652                fragments: vec![
3653                    WebUIFragment::raw("<parent-component"),
3654                    WebUIFragment {
3655                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3656                            WebUIFragmentAttribute {
3657                                name: "title".into(),
3658                                template: "parent-title".into(),
3659                                attr_start: true,
3660                                ..Default::default()
3661                            },
3662                        )),
3663                    },
3664                    WebUIFragment::raw(">"),
3665                    WebUIFragment::component("parent-component"),
3666                    WebUIFragment::raw("</parent-component>"),
3667                ],
3668            },
3669        );
3670        fragments.insert(
3671            "parent-title".to_string(),
3672            FragmentList {
3673                fragments: vec![
3674                    WebUIFragment::raw("Hello "),
3675                    WebUIFragment::signal("who", false),
3676                ],
3677            },
3678        );
3679        fragments.insert(
3680            "parent-component".to_string(),
3681            FragmentList {
3682                fragments: vec![
3683                    WebUIFragment::raw("<child-component"),
3684                    WebUIFragment {
3685                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3686                            WebUIFragmentAttribute {
3687                                name: "title".into(),
3688                                template: "child-title".into(),
3689                                attr_start: true,
3690                                ..Default::default()
3691                            },
3692                        )),
3693                    },
3694                    WebUIFragment::raw(">"),
3695                    WebUIFragment::component("child-component"),
3696                    WebUIFragment::raw("</child-component>"),
3697                ],
3698            },
3699        );
3700        fragments.insert(
3701            "child-title".to_string(),
3702            FragmentList {
3703                fragments: vec![
3704                    WebUIFragment::raw("Child of "),
3705                    WebUIFragment::signal("title", false),
3706                ],
3707            },
3708        );
3709        fragments.insert(
3710            "child-component".to_string(),
3711            FragmentList {
3712                fragments: vec![
3713                    WebUIFragment::raw("<span>"),
3714                    WebUIFragment::signal("title", false),
3715                    WebUIFragment::raw("</span>"),
3716                ],
3717            },
3718        );
3719        let protocol = WebUIProtocol::new(fragments);
3720        let state = test_json!({"who": "<world>"});
3721        let mut writer = TestWriter::new();
3722        handle(
3723            &protocol,
3724            &state,
3725            &RenderOptions::new("index.html", "/"),
3726            &mut writer,
3727        )
3728        .unwrap();
3729        assert_eq!(
3730            writer.get_content(),
3731            "<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>"
3732        );
3733    }
3734
3735    #[test]
3736    fn test_grandchild_attr_propagation() {
3737        let mut fragments = HashMap::new();
3738        fragments.insert(
3739            "index.html".to_string(),
3740            FragmentList {
3741                fragments: vec![
3742                    WebUIFragment::raw("<parent-component"),
3743                    WebUIFragment {
3744                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3745                            WebUIFragmentAttribute {
3746                                name: "title".into(),
3747                                template: "p-title".into(),
3748                                attr_start: true,
3749                                ..Default::default()
3750                            },
3751                        )),
3752                    },
3753                    WebUIFragment::raw(">"),
3754                    WebUIFragment::component("parent-component"),
3755                    WebUIFragment::raw("</parent-component>"),
3756                ],
3757            },
3758        );
3759        fragments.insert(
3760            "p-title".to_string(),
3761            FragmentList {
3762                fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
3763            },
3764        );
3765        fragments.insert(
3766            "parent-component".to_string(),
3767            FragmentList {
3768                fragments: vec![
3769                    WebUIFragment::raw("<child-component"),
3770                    WebUIFragment {
3771                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3772                            WebUIFragmentAttribute {
3773                                name: "title".into(),
3774                                template: "c-title".into(),
3775                                attr_start: true,
3776                                ..Default::default()
3777                            },
3778                        )),
3779                    },
3780                    WebUIFragment::raw(">"),
3781                    WebUIFragment::component("child-component"),
3782                    WebUIFragment::raw("</child-component>"),
3783                ],
3784            },
3785        );
3786        fragments.insert(
3787            "c-title".to_string(),
3788            FragmentList {
3789                fragments: vec![
3790                    WebUIFragment::raw("C("),
3791                    WebUIFragment::signal("title", false),
3792                    WebUIFragment::raw(")-"),
3793                    WebUIFragment::signal("cExtra", false),
3794                ],
3795            },
3796        );
3797        fragments.insert(
3798            "child-component".to_string(),
3799            FragmentList {
3800                fragments: vec![
3801                    WebUIFragment::raw("<grandchild-component"),
3802                    WebUIFragment {
3803                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3804                            WebUIFragmentAttribute {
3805                                name: "title".into(),
3806                                value: "title".into(),
3807                                attr_start: true,
3808                                ..Default::default()
3809                            },
3810                        )),
3811                    },
3812                    WebUIFragment::raw(">"),
3813                    WebUIFragment::component("grandchild-component"),
3814                    WebUIFragment::raw("</grandchild-component>"),
3815                ],
3816            },
3817        );
3818        fragments.insert(
3819            "grandchild-component".to_string(),
3820            FragmentList {
3821                fragments: vec![
3822                    WebUIFragment::raw("<span>"),
3823                    WebUIFragment::signal("title", false),
3824                    WebUIFragment::raw("</span>"),
3825                ],
3826            },
3827        );
3828        let protocol = WebUIProtocol::new(fragments);
3829        let state = test_json!({"p": "<p>", "cExtra": "x&y"});
3830        let mut writer = TestWriter::new();
3831        handle(
3832            &protocol,
3833            &state,
3834            &RenderOptions::new("index.html", "/"),
3835            &mut writer,
3836        )
3837        .unwrap();
3838        assert_eq!(
3839            writer.get_content(),
3840            "<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>"
3841        );
3842    }
3843
3844    #[test]
3845    fn test_for_loop_component_attr() {
3846        let mut fragments = HashMap::new();
3847        fragments.insert(
3848            "index.html".to_string(),
3849            FragmentList {
3850                fragments: vec![
3851                    WebUIFragment::raw("<parent-component"),
3852                    WebUIFragment {
3853                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3854                            WebUIFragmentAttribute {
3855                                name: "title".into(),
3856                                template: "parent-title-loop".into(),
3857                                attr_start: true,
3858                                ..Default::default()
3859                            },
3860                        )),
3861                    },
3862                    WebUIFragment::raw(">"),
3863                    WebUIFragment::component("parent-component"),
3864                    WebUIFragment::raw("</parent-component>"),
3865                ],
3866            },
3867        );
3868        fragments.insert(
3869            "parent-title-loop".to_string(),
3870            FragmentList {
3871                fragments: vec![
3872                    WebUIFragment::raw("Parent:"),
3873                    WebUIFragment::signal("who", false),
3874                ],
3875            },
3876        );
3877        fragments.insert(
3878            "parent-component".to_string(),
3879            FragmentList {
3880                fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
3881            },
3882        );
3883        fragments.insert(
3884            "child-loop".to_string(),
3885            FragmentList {
3886                fragments: vec![
3887                    WebUIFragment::raw("<child-component"),
3888                    WebUIFragment {
3889                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3890                            WebUIFragmentAttribute {
3891                                name: "title".into(),
3892                                template: "child-title-loop".into(),
3893                                attr_start: true,
3894                                ..Default::default()
3895                            },
3896                        )),
3897                    },
3898                    WebUIFragment::raw(">"),
3899                    WebUIFragment::component("child-component"),
3900                    WebUIFragment::raw("</child-component>"),
3901                ],
3902            },
3903        );
3904        fragments.insert(
3905            "child-title-loop".to_string(),
3906            FragmentList {
3907                fragments: vec![
3908                    WebUIFragment::raw("Hi "),
3909                    WebUIFragment::signal("item.name", false),
3910                    WebUIFragment::raw(" / "),
3911                    WebUIFragment::signal("title", false),
3912                ],
3913            },
3914        );
3915        fragments.insert(
3916            "child-component".to_string(),
3917            FragmentList {
3918                fragments: vec![
3919                    WebUIFragment::raw("<span>"),
3920                    WebUIFragment::signal("title", false),
3921                    WebUIFragment::raw("</span>"),
3922                ],
3923            },
3924        );
3925        let protocol = WebUIProtocol::new(fragments);
3926        let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
3927        let mut writer = TestWriter::new();
3928        handle(
3929            &protocol,
3930            &state,
3931            &RenderOptions::new("index.html", "/"),
3932            &mut writer,
3933        )
3934        .unwrap();
3935        assert_eq!(
3936            writer.get_content(),
3937            "<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>"
3938        );
3939    }
3940
3941    #[test]
3942    fn test_multiple_template_attrs() {
3943        let mut fragments = HashMap::new();
3944        fragments.insert(
3945            "index.html".to_string(),
3946            FragmentList {
3947                fragments: vec![
3948                    WebUIFragment::raw("<my-component"),
3949                    WebUIFragment {
3950                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3951                            WebUIFragmentAttribute {
3952                                name: "title".into(),
3953                                template: "attr-title".into(),
3954                                attr_start: true,
3955                                ..Default::default()
3956                            },
3957                        )),
3958                    },
3959                    WebUIFragment {
3960                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3961                            WebUIFragmentAttribute {
3962                                name: "data-title".into(),
3963                                template: "attr-data-title".into(),
3964                                attr_start: false,
3965                                ..Default::default()
3966                            },
3967                        )),
3968                    },
3969                    WebUIFragment {
3970                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3971                            WebUIFragmentAttribute {
3972                                name: "aria-label".into(),
3973                                template: "attr-aria-label".into(),
3974                                attr_start: false,
3975                                ..Default::default()
3976                            },
3977                        )),
3978                    },
3979                    WebUIFragment::raw(">"),
3980                    WebUIFragment::component("my-component"),
3981                    WebUIFragment::raw("</my-component>"),
3982                ],
3983            },
3984        );
3985        fragments.insert(
3986            "attr-title".to_string(),
3987            FragmentList {
3988                fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
3989            },
3990        );
3991        fragments.insert(
3992            "attr-data-title".to_string(),
3993            FragmentList {
3994                fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
3995            },
3996        );
3997        fragments.insert(
3998            "attr-aria-label".to_string(),
3999            FragmentList {
4000                fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
4001            },
4002        );
4003        fragments.insert(
4004            "my-component".to_string(),
4005            FragmentList {
4006                fragments: vec![
4007                    WebUIFragment::raw("<span>"),
4008                    WebUIFragment::signal("title", false),
4009                    WebUIFragment::raw("|"),
4010                    WebUIFragment::signal("dataTitle", false),
4011                    WebUIFragment::raw("|"),
4012                    WebUIFragment::signal("ariaLabel", false),
4013                    WebUIFragment::raw("</span>"),
4014                ],
4015            },
4016        );
4017        let protocol = WebUIProtocol::new(fragments);
4018        let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
4019        let mut writer = TestWriter::new();
4020        handle(
4021            &protocol,
4022            &state,
4023            &RenderOptions::new("index.html", "/"),
4024            &mut writer,
4025        )
4026        .unwrap();
4027        assert_eq!(
4028            writer.get_content(),
4029            "<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>"
4030        );
4031    }
4032
4033    #[test]
4034    fn test_attr_priority_over_global() {
4035        let mut fragments = HashMap::new();
4036        fragments.insert(
4037            "index.html".to_string(),
4038            FragmentList {
4039                fragments: vec![
4040                    WebUIFragment::raw("<my-component"),
4041                    WebUIFragment {
4042                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4043                            WebUIFragmentAttribute {
4044                                name: "title".into(),
4045                                value: "Attribute Title".into(),
4046                                raw_value: true,
4047                                attr_start: true,
4048                                ..Default::default()
4049                            },
4050                        )),
4051                    },
4052                    WebUIFragment::raw(">"),
4053                    WebUIFragment::component("my-component"),
4054                    WebUIFragment::raw("</my-component>"),
4055                ],
4056            },
4057        );
4058        fragments.insert(
4059            "my-component".to_string(),
4060            FragmentList {
4061                fragments: vec![
4062                    WebUIFragment::raw("<span>"),
4063                    WebUIFragment::signal("title", false),
4064                    WebUIFragment::raw("</span>"),
4065                ],
4066            },
4067        );
4068        let protocol = WebUIProtocol::new(fragments);
4069        let state = test_json!({"title": "Global Title"});
4070        let mut writer = TestWriter::new();
4071        handle(
4072            &protocol,
4073            &state,
4074            &RenderOptions::new("index.html", "/"),
4075            &mut writer,
4076        )
4077        .unwrap();
4078        assert_eq!(
4079            writer.get_content(),
4080            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
4081        );
4082    }
4083
4084    #[test]
4085    fn test_attr_priority_over_local_and_global() {
4086        let mut fragments = HashMap::new();
4087        fragments.insert(
4088            "index.html".to_string(),
4089            FragmentList {
4090                fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
4091            },
4092        );
4093        fragments.insert(
4094            "loop".to_string(),
4095            FragmentList {
4096                fragments: vec![
4097                    WebUIFragment::raw("<my-component"),
4098                    WebUIFragment {
4099                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4100                            WebUIFragmentAttribute {
4101                                name: "title".into(),
4102                                value: "Attribute Title".into(),
4103                                raw_value: true,
4104                                attr_start: true,
4105                                ..Default::default()
4106                            },
4107                        )),
4108                    },
4109                    WebUIFragment::raw(">"),
4110                    WebUIFragment::component("my-component"),
4111                    WebUIFragment::raw("</my-component>"),
4112                ],
4113            },
4114        );
4115        fragments.insert(
4116            "my-component".to_string(),
4117            FragmentList {
4118                fragments: vec![
4119                    WebUIFragment::raw("<span>"),
4120                    WebUIFragment::signal("title", false),
4121                    WebUIFragment::raw("</span>"),
4122                ],
4123            },
4124        );
4125        let protocol = WebUIProtocol::new(fragments);
4126        let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
4127        let mut writer = TestWriter::new();
4128        handle(
4129            &protocol,
4130            &state,
4131            &RenderOptions::new("index.html", "/"),
4132            &mut writer,
4133        )
4134        .unwrap();
4135        assert_eq!(
4136            writer.get_content(),
4137            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
4138        );
4139    }
4140
4141    #[test]
4142    fn test_boolean_attr_first_component_attr() {
4143        let mut fragments = HashMap::new();
4144        fragments.insert(
4145            "index.html".to_string(),
4146            FragmentList {
4147                fragments: vec![
4148                    WebUIFragment::raw("<my-component"),
4149                    WebUIFragment {
4150                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4151                            WebUIFragmentAttribute {
4152                                name: "disabled".into(),
4153                                attr_start: true,
4154                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4155                                ..Default::default()
4156                            },
4157                        )),
4158                    },
4159                    WebUIFragment {
4160                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4161                            WebUIFragmentAttribute {
4162                                name: "label".into(),
4163                                value: "Component Label".into(),
4164                                raw_value: true,
4165                                attr_start: false,
4166                                ..Default::default()
4167                            },
4168                        )),
4169                    },
4170                    WebUIFragment::raw(">"),
4171                    WebUIFragment::component("my-component"),
4172                    WebUIFragment::raw("</my-component>"),
4173                ],
4174            },
4175        );
4176        fragments.insert(
4177            "my-component".to_string(),
4178            FragmentList {
4179                fragments: vec![
4180                    WebUIFragment::if_cond(
4181                        ConditionExpr::identifier("disabled"),
4182                        "disabledTemplate",
4183                    ),
4184                    WebUIFragment::raw("<span>"),
4185                    WebUIFragment::signal("label", false),
4186                    WebUIFragment::raw("</span>"),
4187                ],
4188            },
4189        );
4190        fragments.insert(
4191            "disabledTemplate".to_string(),
4192            FragmentList {
4193                fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
4194            },
4195        );
4196        let protocol = WebUIProtocol::new(fragments);
4197        let state = test_json!({"isDisabled": true});
4198        let mut writer = TestWriter::new();
4199        handle(
4200            &protocol,
4201            &state,
4202            &RenderOptions::new("index.html", "/"),
4203            &mut writer,
4204        )
4205        .unwrap();
4206        assert_eq!(
4207            writer.get_content(),
4208            "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
4209        );
4210    }
4211
4212    #[test]
4213    fn test_hyphenated_attr_camelcase() {
4214        let mut fragments = HashMap::new();
4215        fragments.insert(
4216            "index.html".to_string(),
4217            FragmentList {
4218                fragments: vec![
4219                    WebUIFragment::raw("<my-component"),
4220                    WebUIFragment {
4221                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4222                            WebUIFragmentAttribute {
4223                                name: "key-hyphen".into(),
4224                                value: "Local Value".into(),
4225                                raw_value: true,
4226                                attr_start: true,
4227                                ..Default::default()
4228                            },
4229                        )),
4230                    },
4231                    WebUIFragment::raw(">"),
4232                    WebUIFragment::component("my-component"),
4233                    WebUIFragment::raw("</my-component>"),
4234                ],
4235            },
4236        );
4237        fragments.insert(
4238            "my-component".to_string(),
4239            FragmentList {
4240                fragments: vec![
4241                    WebUIFragment::raw("<span>"),
4242                    WebUIFragment::signal("keyHyphen", false),
4243                    WebUIFragment::raw("</span>"),
4244                ],
4245            },
4246        );
4247        let protocol = WebUIProtocol::new(fragments);
4248        let state = test_json!({"keyHyphen": "Global Value"});
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 key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
4260        );
4261    }
4262
4263    #[test]
4264    fn test_skipped_component_attrs() {
4265        // Skipped attributes: class, style, role, data-*, aria-*
4266        // Plus framework-specific prefixes/names that the parser marks with attr_skip.
4267        // These render on the HTML element but are NOT passed into component attribute state.
4268        let mut fragments = HashMap::new();
4269        fragments.insert(
4270            "index.html".to_string(),
4271            FragmentList {
4272                fragments: vec![
4273                    WebUIFragment::raw("<test-component"),
4274                    // Skipped: class
4275                    WebUIFragment {
4276                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4277                            WebUIFragmentAttribute {
4278                                name: "class".into(),
4279                                value: "skippedClass".into(),
4280                                attr_start: true,
4281                                attr_skip: true,
4282                                ..Default::default()
4283                            },
4284                        )),
4285                    },
4286                    // Skipped: style
4287                    WebUIFragment {
4288                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4289                            WebUIFragmentAttribute {
4290                                name: "style".into(),
4291                                value: "skippedStyle".into(),
4292                                attr_skip: true,
4293                                ..Default::default()
4294                            },
4295                        )),
4296                    },
4297                    // Skipped: role
4298                    WebUIFragment {
4299                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4300                            WebUIFragmentAttribute {
4301                                name: "role".into(),
4302                                value: "skippedRole".into(),
4303                                attr_skip: true,
4304                                ..Default::default()
4305                            },
4306                        )),
4307                    },
4308                    // Skipped: data-testid (data-* prefix)
4309                    WebUIFragment {
4310                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4311                            WebUIFragmentAttribute {
4312                                name: "data-testid".into(),
4313                                value: "skippedDataTestid".into(),
4314                                attr_skip: true,
4315                                ..Default::default()
4316                            },
4317                        )),
4318                    },
4319                    // Skipped: aria-label (aria-* prefix)
4320                    WebUIFragment {
4321                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4322                            WebUIFragmentAttribute {
4323                                name: "aria-label".into(),
4324                                value: "skippedAriaLabel".into(),
4325                                attr_skip: true,
4326                                ..Default::default()
4327                            },
4328                        )),
4329                    },
4330                    // NOT skipped: title
4331                    WebUIFragment {
4332                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4333                            WebUIFragmentAttribute {
4334                                name: "title".into(),
4335                                value: "title".into(),
4336                                ..Default::default()
4337                            },
4338                        )),
4339                    },
4340                    WebUIFragment::raw(">"),
4341                    WebUIFragment::component("test-component"),
4342                    WebUIFragment::raw("</test-component>"),
4343                ],
4344            },
4345        );
4346        fragments.insert(
4347            "test-component".to_string(),
4348            FragmentList {
4349                fragments: vec![
4350                    WebUIFragment::raw("<span>"),
4351                    WebUIFragment::signal("title", false),
4352                    WebUIFragment::raw("-"),
4353                    WebUIFragment::signal("class", false),
4354                    WebUIFragment::raw("-"),
4355                    WebUIFragment::signal("style", false),
4356                    WebUIFragment::raw("-"),
4357                    WebUIFragment::signal("role", false),
4358                    WebUIFragment::raw("-"),
4359                    WebUIFragment::signal("dataTestid", false),
4360                    WebUIFragment::raw("-"),
4361                    WebUIFragment::signal("ariaLabel", false),
4362                    WebUIFragment::raw("</span>"),
4363                ],
4364            },
4365        );
4366        let protocol = WebUIProtocol::new(fragments);
4367        let state = test_json!({
4368            "title": "Hello",
4369            "skippedClass": "my-class",
4370            "skippedStyle": "color:red",
4371            "skippedRole": "button",
4372            "skippedDataTestid": "test-id",
4373            "skippedAriaLabel": "label-text"
4374        });
4375        let mut writer = TestWriter::new();
4376        handle(
4377            &protocol,
4378            &state,
4379            &RenderOptions::new("index.html", "/"),
4380            &mut writer,
4381        )
4382        .unwrap();
4383        // Skipped attrs render on the element but their values are NOT accessible inside the component.
4384        // The component's signals for skipped attrs resolve to empty strings.
4385        // Only "title" (non-skipped) is accessible.
4386        assert_eq!(
4387            writer.get_content(),
4388            "<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>"
4389        );
4390    }
4391
4392    // ── GROUP 7: Attribute Inheritance ─────────────────────────────────
4393
4394    #[test]
4395    fn test_attr_inherit_parent_to_child() {
4396        let mut fragments = HashMap::new();
4397        fragments.insert(
4398            "index.html".to_string(),
4399            FragmentList {
4400                fragments: vec![
4401                    WebUIFragment::raw("<parent-component"),
4402                    WebUIFragment {
4403                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4404                            WebUIFragmentAttribute {
4405                                name: "title".into(),
4406                                value: "Parent Title".into(),
4407                                raw_value: true,
4408                                attr_start: true,
4409                                ..Default::default()
4410                            },
4411                        )),
4412                    },
4413                    WebUIFragment::raw(">"),
4414                    WebUIFragment::component("parent-component"),
4415                    WebUIFragment::raw("</parent-component>"),
4416                ],
4417            },
4418        );
4419        fragments.insert(
4420            "parent-component".to_string(),
4421            FragmentList {
4422                fragments: vec![
4423                    WebUIFragment::raw("<h1>"),
4424                    WebUIFragment::signal("title", false),
4425                    WebUIFragment::raw("</h1><child-component"),
4426                    WebUIFragment {
4427                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4428                            WebUIFragmentAttribute {
4429                                name: "title".into(),
4430                                value: "title".into(),
4431                                attr_start: true,
4432                                ..Default::default()
4433                            },
4434                        )),
4435                    },
4436                    WebUIFragment::raw(">"),
4437                    WebUIFragment::component("child-component"),
4438                    WebUIFragment::raw("</child-component>"),
4439                ],
4440            },
4441        );
4442        fragments.insert(
4443            "child-component".to_string(),
4444            FragmentList {
4445                fragments: vec![
4446                    WebUIFragment::raw("<h2>"),
4447                    WebUIFragment::signal("title", false),
4448                    WebUIFragment::raw("</h2>"),
4449                ],
4450            },
4451        );
4452        let protocol = WebUIProtocol::new(fragments);
4453        let state = test_json!({});
4454        let mut writer = TestWriter::new();
4455        handle(
4456            &protocol,
4457            &state,
4458            &RenderOptions::new("index.html", "/"),
4459            &mut writer,
4460        )
4461        .unwrap();
4462        assert_eq!(
4463            writer.get_content(),
4464            "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
4465        );
4466    }
4467
4468    #[test]
4469    fn test_attr_inherit_deep() {
4470        let mut fragments = HashMap::new();
4471        fragments.insert(
4472            "index.html".to_string(),
4473            FragmentList {
4474                fragments: vec![
4475                    WebUIFragment::raw("<parent-component"),
4476                    WebUIFragment {
4477                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4478                            WebUIFragmentAttribute {
4479                                name: "title".into(),
4480                                value: "Parent Title".into(),
4481                                raw_value: true,
4482                                attr_start: true,
4483                                ..Default::default()
4484                            },
4485                        )),
4486                    },
4487                    WebUIFragment::raw(">"),
4488                    WebUIFragment::component("parent-component"),
4489                    WebUIFragment::raw("</parent-component>"),
4490                ],
4491            },
4492        );
4493        fragments.insert(
4494            "parent-component".to_string(),
4495            FragmentList {
4496                fragments: vec![
4497                    WebUIFragment::raw("<child-component"),
4498                    WebUIFragment {
4499                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4500                            WebUIFragmentAttribute {
4501                                name: "title".into(),
4502                                value: "Child Title".into(),
4503                                raw_value: true,
4504                                attr_start: true,
4505                                ..Default::default()
4506                            },
4507                        )),
4508                    },
4509                    WebUIFragment::raw(">"),
4510                    WebUIFragment::component("child-component"),
4511                    WebUIFragment::raw("</child-component>"),
4512                ],
4513            },
4514        );
4515        fragments.insert(
4516            "child-component".to_string(),
4517            FragmentList {
4518                fragments: vec![
4519                    WebUIFragment::raw("<grandchild-component"),
4520                    WebUIFragment {
4521                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4522                            WebUIFragmentAttribute {
4523                                name: "title".into(),
4524                                value: "title".into(),
4525                                attr_start: true,
4526                                ..Default::default()
4527                            },
4528                        )),
4529                    },
4530                    WebUIFragment::raw(">"),
4531                    WebUIFragment::component("grandchild-component"),
4532                    WebUIFragment::raw("</grandchild-component>"),
4533                ],
4534            },
4535        );
4536        fragments.insert(
4537            "grandchild-component".to_string(),
4538            FragmentList {
4539                fragments: vec![
4540                    WebUIFragment::raw("<h3>"),
4541                    WebUIFragment::signal("title", false),
4542                    WebUIFragment::raw("</h3>"),
4543                ],
4544            },
4545        );
4546        let protocol = WebUIProtocol::new(fragments);
4547        let state = test_json!({});
4548        let mut writer = TestWriter::new();
4549        handle(
4550            &protocol,
4551            &state,
4552            &RenderOptions::new("index.html", "/"),
4553            &mut writer,
4554        )
4555        .unwrap();
4556        assert_eq!(
4557            writer.get_content(),
4558            "<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>"
4559        );
4560    }
4561
4562    #[test]
4563    fn test_complex_attr_access() {
4564        let mut fragments = HashMap::new();
4565        fragments.insert(
4566            "index.html".to_string(),
4567            FragmentList {
4568                fragments: vec![
4569                    WebUIFragment::raw("<my-component"),
4570                    WebUIFragment {
4571                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4572                            WebUIFragmentAttribute {
4573                                name: ":item".into(),
4574                                value: "complexItem".into(),
4575                                attr_start: true,
4576                                complex: true,
4577                                ..Default::default()
4578                            },
4579                        )),
4580                    },
4581                    WebUIFragment::raw(">"),
4582                    WebUIFragment::component("my-component"),
4583                    WebUIFragment::raw("</my-component>"),
4584                ],
4585            },
4586        );
4587        fragments.insert(
4588            "my-component".to_string(),
4589            FragmentList {
4590                fragments: vec![
4591                    WebUIFragment::raw("<span>"),
4592                    WebUIFragment::signal("item.foo", false),
4593                    WebUIFragment::raw("</span><p>"),
4594                    WebUIFragment::signal("item.bar", false),
4595                    WebUIFragment::raw("</p>"),
4596                ],
4597            },
4598        );
4599        let protocol = WebUIProtocol::new(fragments);
4600        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
4601        let mut writer = TestWriter::new();
4602        handle(
4603            &protocol,
4604            &state,
4605            &RenderOptions::new("index.html", "/"),
4606            &mut writer,
4607        )
4608        .unwrap();
4609        assert_eq!(
4610            writer.get_content(),
4611            "<my-component><span>1</span><p>true</p></my-component>"
4612        );
4613    }
4614
4615    #[test]
4616    fn test_complex_attr_for_loop() {
4617        let mut fragments = HashMap::new();
4618        fragments.insert(
4619            "index.html".to_string(),
4620            FragmentList {
4621                fragments: vec![WebUIFragment::for_loop(
4622                    "item",
4623                    "list.items",
4624                    "listTemplate",
4625                )],
4626            },
4627        );
4628        fragments.insert(
4629            "listTemplate".to_string(),
4630            FragmentList {
4631                fragments: vec![
4632                    WebUIFragment {
4633                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4634                            WebUIFragmentAttribute {
4635                                name: ":item".into(),
4636                                value: "item".into(),
4637                                attr_start: true,
4638                                complex: true,
4639                                ..Default::default()
4640                            },
4641                        )),
4642                    },
4643                    WebUIFragment::component("item_component"),
4644                ],
4645            },
4646        );
4647        fragments.insert(
4648            "item_component".to_string(),
4649            FragmentList {
4650                fragments: vec![
4651                    WebUIFragment::raw("<span>"),
4652                    WebUIFragment::signal("item.name", false),
4653                    WebUIFragment::raw("</span>"),
4654                ],
4655            },
4656        );
4657        let protocol = WebUIProtocol::new(fragments);
4658        let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
4659        let mut writer = TestWriter::new();
4660        handle(
4661            &protocol,
4662            &state,
4663            &RenderOptions::new("index.html", "/"),
4664            &mut writer,
4665        )
4666        .unwrap();
4667        assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
4668    }
4669
4670    #[test]
4671    fn test_complex_attr_nested_for() {
4672        let mut fragments = HashMap::new();
4673        fragments.insert(
4674            "index.html".to_string(),
4675            FragmentList {
4676                fragments: vec![WebUIFragment::for_loop(
4677                    "outer",
4678                    "data.outer",
4679                    "outerTemplate",
4680                )],
4681            },
4682        );
4683        fragments.insert(
4684            "outerTemplate".to_string(),
4685            FragmentList {
4686                fragments: vec![WebUIFragment::for_loop(
4687                    "middle",
4688                    "outer.middle",
4689                    "middleTemplate",
4690                )],
4691            },
4692        );
4693        fragments.insert(
4694            "middleTemplate".to_string(),
4695            FragmentList {
4696                fragments: vec![WebUIFragment::for_loop(
4697                    "inner",
4698                    "middle.inner",
4699                    "innerTemplate",
4700                )],
4701            },
4702        );
4703        fragments.insert(
4704            "innerTemplate".to_string(),
4705            FragmentList {
4706                fragments: vec![
4707                    WebUIFragment::raw("<card"),
4708                    WebUIFragment {
4709                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4710                            WebUIFragmentAttribute {
4711                                name: ":outer".into(),
4712                                value: "outer".into(),
4713                                attr_start: true,
4714                                complex: true,
4715                                ..Default::default()
4716                            },
4717                        )),
4718                    },
4719                    WebUIFragment {
4720                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4721                            WebUIFragmentAttribute {
4722                                name: ":middle".into(),
4723                                value: "middle".into(),
4724                                attr_start: false,
4725                                complex: true,
4726                                ..Default::default()
4727                            },
4728                        )),
4729                    },
4730                    WebUIFragment {
4731                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4732                            WebUIFragmentAttribute {
4733                                name: ":inner".into(),
4734                                value: "inner".into(),
4735                                attr_start: false,
4736                                complex: true,
4737                                ..Default::default()
4738                            },
4739                        )),
4740                    },
4741                    WebUIFragment::raw(">"),
4742                    WebUIFragment::component("card_component"),
4743                    WebUIFragment::raw("</card>"),
4744                ],
4745            },
4746        );
4747        fragments.insert(
4748            "card_component".to_string(),
4749            FragmentList {
4750                fragments: vec![
4751                    WebUIFragment::raw("<p>"),
4752                    WebUIFragment::signal("outer.label", false),
4753                    WebUIFragment::raw(" / "),
4754                    WebUIFragment::signal("middle.label", false),
4755                    WebUIFragment::raw(" / "),
4756                    WebUIFragment::signal("inner.label", false),
4757                    WebUIFragment::raw("</p>"),
4758                ],
4759            },
4760        );
4761        let protocol = WebUIProtocol::new(fragments);
4762        let state = test_json!({"data": {"outer": [
4763            {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
4764            {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
4765        ]}});
4766        let mut writer = TestWriter::new();
4767        handle(
4768            &protocol,
4769            &state,
4770            &RenderOptions::new("index.html", "/"),
4771            &mut writer,
4772        )
4773        .unwrap();
4774        assert_eq!(
4775            writer.get_content(),
4776            "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
4777        );
4778    }
4779
4780    // ── GROUP 8: Boolean Component State ──────────────────────────────
4781
4782    #[test]
4783    fn test_bool_component_state_true() {
4784        let mut fragments = HashMap::new();
4785        fragments.insert(
4786            "index.html".to_string(),
4787            FragmentList {
4788                fragments: vec![
4789                    WebUIFragment::raw("<my-component"),
4790                    WebUIFragment {
4791                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4792                            WebUIFragmentAttribute {
4793                                name: "disabled".into(),
4794                                attr_start: true,
4795                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4796                                ..Default::default()
4797                            },
4798                        )),
4799                    },
4800                    WebUIFragment::raw(">"),
4801                    WebUIFragment::component("my-component"),
4802                    WebUIFragment::raw("</my-component>"),
4803                ],
4804            },
4805        );
4806        fragments.insert(
4807            "my-component".to_string(),
4808            FragmentList {
4809                fragments: vec![
4810                    WebUIFragment::if_cond(
4811                        ConditionExpr::identifier("disabled"),
4812                        "disabledTemplate",
4813                    ),
4814                    WebUIFragment::if_cond(
4815                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
4816                        "enabledTemplate",
4817                    ),
4818                ],
4819            },
4820        );
4821        fragments.insert(
4822            "disabledTemplate".to_string(),
4823            FragmentList {
4824                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
4825            },
4826        );
4827        fragments.insert(
4828            "enabledTemplate".to_string(),
4829            FragmentList {
4830                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
4831            },
4832        );
4833        let protocol = WebUIProtocol::new(fragments);
4834        let state = test_json!({"isDisabled": true});
4835        let mut writer = TestWriter::new();
4836        handle(
4837            &protocol,
4838            &state,
4839            &RenderOptions::new("index.html", "/"),
4840            &mut writer,
4841        )
4842        .unwrap();
4843        assert_eq!(
4844            writer.get_content(),
4845            "<my-component disabled><span>Disabled</span></my-component>"
4846        );
4847    }
4848
4849    #[test]
4850    fn test_bool_component_state_false() {
4851        let mut fragments = HashMap::new();
4852        fragments.insert(
4853            "index.html".to_string(),
4854            FragmentList {
4855                fragments: vec![
4856                    WebUIFragment::raw("<my-component"),
4857                    WebUIFragment {
4858                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4859                            WebUIFragmentAttribute {
4860                                name: "disabled".into(),
4861                                attr_start: true,
4862                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4863                                ..Default::default()
4864                            },
4865                        )),
4866                    },
4867                    WebUIFragment::raw(">"),
4868                    WebUIFragment::component("my-component"),
4869                    WebUIFragment::raw("</my-component>"),
4870                ],
4871            },
4872        );
4873        fragments.insert(
4874            "my-component".to_string(),
4875            FragmentList {
4876                fragments: vec![
4877                    WebUIFragment::if_cond(
4878                        ConditionExpr::identifier("disabled"),
4879                        "disabledTemplate",
4880                    ),
4881                    WebUIFragment::if_cond(
4882                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
4883                        "enabledTemplate",
4884                    ),
4885                ],
4886            },
4887        );
4888        fragments.insert(
4889            "disabledTemplate".to_string(),
4890            FragmentList {
4891                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
4892            },
4893        );
4894        fragments.insert(
4895            "enabledTemplate".to_string(),
4896            FragmentList {
4897                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
4898            },
4899        );
4900        let protocol = WebUIProtocol::new(fragments);
4901        let state = test_json!({"isDisabled": false});
4902        let mut writer = TestWriter::new();
4903        handle(
4904            &protocol,
4905            &state,
4906            &RenderOptions::new("index.html", "/"),
4907            &mut writer,
4908        )
4909        .unwrap();
4910        assert_eq!(
4911            writer.get_content(),
4912            "<my-component><span>Enabled</span></my-component>"
4913        );
4914    }
4915
4916    #[test]
4917    fn test_bool_component_state_forward() {
4918        let mut fragments = HashMap::new();
4919        fragments.insert(
4920            "index.html".to_string(),
4921            FragmentList {
4922                fragments: vec![
4923                    WebUIFragment::raw("<parent-component"),
4924                    WebUIFragment {
4925                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4926                            WebUIFragmentAttribute {
4927                                name: "disabled".into(),
4928                                attr_start: true,
4929                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4930                                ..Default::default()
4931                            },
4932                        )),
4933                    },
4934                    WebUIFragment::raw(">"),
4935                    WebUIFragment::component("parent-component"),
4936                    WebUIFragment::raw("</parent-component>"),
4937                ],
4938            },
4939        );
4940        fragments.insert(
4941            "parent-component".to_string(),
4942            FragmentList {
4943                fragments: vec![
4944                    WebUIFragment::if_cond(
4945                        ConditionExpr::identifier("disabled"),
4946                        "parentDisabledTemplate",
4947                    ),
4948                    WebUIFragment::raw("<child-component"),
4949                    WebUIFragment {
4950                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4951                            WebUIFragmentAttribute {
4952                                name: "disabled".into(),
4953                                attr_start: true,
4954                                condition_tree: Some(ConditionExpr::identifier("disabled")),
4955                                ..Default::default()
4956                            },
4957                        )),
4958                    },
4959                    WebUIFragment::raw(">"),
4960                    WebUIFragment::component("child-component"),
4961                    WebUIFragment::raw("</child-component>"),
4962                ],
4963            },
4964        );
4965        fragments.insert(
4966            "parentDisabledTemplate".to_string(),
4967            FragmentList {
4968                fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
4969            },
4970        );
4971        fragments.insert(
4972            "child-component".to_string(),
4973            FragmentList {
4974                fragments: vec![
4975                    WebUIFragment::if_cond(
4976                        ConditionExpr::identifier("disabled"),
4977                        "childDisabledTemplate",
4978                    ),
4979                    WebUIFragment::if_cond(
4980                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
4981                        "childEnabledTemplate",
4982                    ),
4983                ],
4984            },
4985        );
4986        fragments.insert(
4987            "childDisabledTemplate".to_string(),
4988            FragmentList {
4989                fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
4990            },
4991        );
4992        fragments.insert(
4993            "childEnabledTemplate".to_string(),
4994            FragmentList {
4995                fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
4996            },
4997        );
4998
4999        // Test case 1: isDisabled = true
5000        {
5001            let protocol = WebUIProtocol::new(fragments.clone());
5002            let state = test_json!({"isDisabled": true});
5003            let mut writer = TestWriter::new();
5004            handle(
5005                &protocol,
5006                &state,
5007                &RenderOptions::new("index.html", "/"),
5008                &mut writer,
5009            )
5010            .unwrap();
5011            assert_eq!(
5012                writer.get_content(),
5013                "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
5014            );
5015        }
5016
5017        // Test case 2: isDisabled = false
5018        {
5019            let protocol = WebUIProtocol::new(fragments.clone());
5020            let state = test_json!({"isDisabled": false});
5021            let mut writer = TestWriter::new();
5022            handle(
5023                &protocol,
5024                &state,
5025                &RenderOptions::new("index.html", "/"),
5026                &mut writer,
5027            )
5028            .unwrap();
5029            assert_eq!(
5030                writer.get_content(),
5031                "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
5032            );
5033        }
5034    }
5035
5036    // ── GROUP 9: Hydration (SKIP) ─────────────────────────────────────
5037
5038    // TODO: test_hydration - requires FAST handler plugin integration; see plugin/fast.rs
5039
5040    // ── Component tests ──────────────────────────────────────────────
5041
5042    #[test]
5043    fn test_component_with_template() {
5044        let mut fragments = HashMap::new();
5045        fragments.insert(
5046            "index.html".to_string(),
5047            FragmentList {
5048                fragments: vec![
5049                    WebUIFragment::raw("<custom-element>"),
5050                    WebUIFragment::component("custom-element"),
5051                    WebUIFragment::raw("</custom-element>"),
5052                ],
5053            },
5054        );
5055        fragments.insert(
5056            "custom-element".to_string(),
5057            FragmentList {
5058                fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
5059            },
5060        );
5061        let protocol = WebUIProtocol::new(fragments);
5062        let state = test_json!({});
5063        let mut writer = TestWriter::new();
5064        handle(
5065            &protocol,
5066            &state,
5067            &RenderOptions::new("index.html", "/"),
5068            &mut writer,
5069        )
5070        .unwrap();
5071        assert_eq!(
5072            writer.get_content(),
5073            "<custom-element><div>Custom Element</div></custom-element>"
5074        );
5075        assert!(writer.is_ended());
5076    }
5077
5078    #[test]
5079    fn test_component_with_slots() {
5080        let mut fragments = HashMap::new();
5081        fragments.insert(
5082            "index.html".to_string(),
5083            FragmentList {
5084                fragments: vec![
5085                    WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
5086                    WebUIFragment::component("custom-element"),
5087                    WebUIFragment::raw("Hello World</custom-element>"),
5088                ],
5089            },
5090        );
5091        fragments.insert(
5092            "custom-element".to_string(),
5093            FragmentList {
5094                fragments: vec![WebUIFragment::raw("<slot></slot>")],
5095            },
5096        );
5097        let protocol = WebUIProtocol::new(fragments);
5098        let state = test_json!({});
5099        let mut writer = TestWriter::new();
5100        handle(
5101            &protocol,
5102            &state,
5103            &RenderOptions::new("index.html", "/"),
5104            &mut writer,
5105        )
5106        .unwrap();
5107        assert_eq!(
5108            writer.get_content(),
5109            "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
5110        );
5111        assert!(writer.is_ended());
5112    }
5113
5114    #[test]
5115    fn test_multiple_nested_components() {
5116        let mut fragments = HashMap::new();
5117        fragments.insert(
5118            "index.html".to_string(),
5119            FragmentList {
5120                fragments: vec![
5121                    WebUIFragment::raw("<div>"),
5122                    WebUIFragment::for_loop("item", "items", "templateRepeat"),
5123                    WebUIFragment::raw("</div>"),
5124                ],
5125            },
5126        );
5127        fragments.insert(
5128            "custom-button".to_string(),
5129            FragmentList {
5130                fragments: vec![WebUIFragment::raw("<slot></slot>")],
5131            },
5132        );
5133        fragments.insert(
5134            "custom-element".to_string(),
5135            FragmentList {
5136                fragments: vec![
5137                    WebUIFragment::raw("<custom-child>"),
5138                    WebUIFragment::component("custom-child"),
5139                    WebUIFragment::raw("</custom-child><slot></slot>"),
5140                ],
5141            },
5142        );
5143        fragments.insert(
5144            "custom-child".to_string(),
5145            FragmentList {
5146                fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
5147            },
5148        );
5149        fragments.insert(
5150            "templateRepeat".to_string(),
5151            FragmentList {
5152                fragments: vec![
5153                    WebUIFragment::raw("<custom-element>"),
5154                    WebUIFragment::component("custom-element"),
5155                    WebUIFragment::raw("<custom-button>"),
5156                    WebUIFragment::component("custom-button"),
5157                    WebUIFragment::raw("Ok</custom-button></custom-element>"),
5158                ],
5159            },
5160        );
5161        let protocol = WebUIProtocol::new(fragments);
5162        let state = test_json!({"items": [{"name": "Item1"}]});
5163        let mut writer = TestWriter::new();
5164        handle(
5165            &protocol,
5166            &state,
5167            &RenderOptions::new("index.html", "/"),
5168            &mut writer,
5169        )
5170        .unwrap();
5171        assert_eq!(
5172            writer.get_content(),
5173            "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
5174        );
5175        assert!(writer.is_ended());
5176    }
5177
5178    // ── Conditional tests ────────────────────────────────────────────
5179
5180    #[test]
5181    fn test_if_with_binary_expression() {
5182        let mut fragments = HashMap::new();
5183        fragments.insert(
5184            "index.html".to_string(),
5185            FragmentList {
5186                fragments: vec![
5187                    WebUIFragment::raw("<div>"),
5188                    WebUIFragment::if_cond(
5189                        ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
5190                        "if-1",
5191                    ),
5192                    WebUIFragment::raw("</div>"),
5193                ],
5194            },
5195        );
5196        fragments.insert(
5197            "if-1".to_string(),
5198            FragmentList {
5199                fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
5200            },
5201        );
5202        let protocol = WebUIProtocol::new(fragments);
5203
5204        // True case: x = 10 > 5
5205        let state_true = test_json!({"x": 10});
5206        let mut writer_true = TestWriter::new();
5207        handle(
5208            &protocol,
5209            &state_true,
5210            &RenderOptions::new("index.html", "/"),
5211            &mut writer_true,
5212        )
5213        .unwrap();
5214        assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
5215
5216        // False case: x = 1 <= 5
5217        let state_false = test_json!({"x": 1});
5218        let mut writer_false = TestWriter::new();
5219        handle(
5220            &protocol,
5221            &state_false,
5222            &RenderOptions::new("index.html", "/"),
5223            &mut writer_false,
5224        )
5225        .unwrap();
5226        assert_eq!(writer_false.get_content(), "<div></div>");
5227    }
5228
5229    #[test]
5230    fn test_for_if_overlapping_local_state() {
5231        let mut fragments = HashMap::new();
5232        fragments.insert(
5233            "index.html".to_string(),
5234            FragmentList {
5235                fragments: vec![
5236                    WebUIFragment::raw("<div>"),
5237                    WebUIFragment::for_loop("item", "items", "template1"),
5238                    WebUIFragment::raw("</div>"),
5239                ],
5240            },
5241        );
5242        fragments.insert(
5243            "template1".to_string(),
5244            FragmentList {
5245                fragments: vec![
5246                    WebUIFragment::raw("<div>"),
5247                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
5248                    WebUIFragment::raw("</div>"),
5249                ],
5250            },
5251        );
5252        fragments.insert(
5253            "ifBlock".to_string(),
5254            FragmentList {
5255                fragments: vec![
5256                    WebUIFragment::raw("<span>"),
5257                    WebUIFragment::signal("item.label", false),
5258                    WebUIFragment::raw("</span>"),
5259                ],
5260            },
5261        );
5262        let protocol = WebUIProtocol::new(fragments);
5263        let state = test_json!({
5264            "flag": false,
5265            "items": [
5266                {"label": "A", "flag": true},
5267                {"label": "B", "flag": false},
5268                {"label": "C", "flag": true}
5269            ]
5270        });
5271        let mut writer = TestWriter::new();
5272        handle(
5273            &protocol,
5274            &state,
5275            &RenderOptions::new("index.html", "/"),
5276            &mut writer,
5277        )
5278        .unwrap();
5279        assert_eq!(
5280            writer.get_content(),
5281            "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
5282        );
5283    }
5284
5285    #[test]
5286    fn test_for_if_global_flag_no_effect() {
5287        let mut fragments = HashMap::new();
5288        fragments.insert(
5289            "index.html".to_string(),
5290            FragmentList {
5291                fragments: vec![
5292                    WebUIFragment::raw("<div>"),
5293                    WebUIFragment::for_loop("item", "items", "template1"),
5294                    WebUIFragment::raw("</div>"),
5295                ],
5296            },
5297        );
5298        fragments.insert(
5299            "template1".to_string(),
5300            FragmentList {
5301                fragments: vec![
5302                    WebUIFragment::raw("<div>"),
5303                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
5304                    WebUIFragment::raw("</div>"),
5305                ],
5306            },
5307        );
5308        fragments.insert(
5309            "ifBlock".to_string(),
5310            FragmentList {
5311                fragments: vec![
5312                    WebUIFragment::raw("<span>"),
5313                    WebUIFragment::signal("item.label", false),
5314                    WebUIFragment::raw("</span>"),
5315                ],
5316            },
5317        );
5318        let protocol = WebUIProtocol::new(fragments);
5319        let state = test_json!({
5320            "item": {"flag": true},
5321            "items": [
5322                {"label": "A", "flag": false},
5323                {"label": "B", "flag": true},
5324                {"label": "C", "flag": false}
5325            ]
5326        });
5327        let mut writer = TestWriter::new();
5328        handle(
5329            &protocol,
5330            &state,
5331            &RenderOptions::new("index.html", "/"),
5332            &mut writer,
5333        )
5334        .unwrap();
5335        assert_eq!(
5336            writer.get_content(),
5337            "<div><div></div><div><span>B</span></div><div></div></div>"
5338        );
5339    }
5340
5341    // ── Recursive template test ──────────────────────────────────────
5342
5343    #[test]
5344    fn test_recursive_template_refs() {
5345        let mut fragments = HashMap::new();
5346        fragments.insert(
5347            "index.html".to_string(),
5348            FragmentList {
5349                fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
5350            },
5351        );
5352        fragments.insert(
5353            "static".to_string(),
5354            FragmentList {
5355                fragments: vec![
5356                    WebUIFragment::raw("<div expanded=\""),
5357                    WebUIFragment::signal("item.expanded", false),
5358                    WebUIFragment::raw("\" class=\""),
5359                    WebUIFragment::signal("testScenario", false),
5360                    WebUIFragment::raw("\"><span>"),
5361                    WebUIFragment::signal("item.name", false),
5362                    WebUIFragment::raw("</span>"),
5363                    WebUIFragment::for_loop("item", "item.children", "static"),
5364                    WebUIFragment::raw("</div>"),
5365                ],
5366            },
5367        );
5368        let protocol = WebUIProtocol::new(fragments);
5369        let state = test_json!({
5370            "testScenario": "RecursiveTemplatesWithGlobalState",
5371            "items": [
5372                {"name": "A", "expanded": "false", "children": []},
5373                {"name": "B", "expanded": "true", "children": [
5374                    {"name": "C", "expanded": "false"},
5375                    {"name": "D", "expanded": "false"}
5376                ]},
5377                {"name": "E", "expanded": "false"}
5378            ]
5379        });
5380        let mut writer = TestWriter::new();
5381        handle(
5382            &protocol,
5383            &state,
5384            &RenderOptions::new("index.html", "/"),
5385            &mut writer,
5386        )
5387        .unwrap();
5388        assert_eq!(
5389            writer.get_content(),
5390            "<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>"
5391        );
5392    }
5393
5394    // ── Advanced state management tests ──────────────────────────────
5395
5396    #[test]
5397    fn test_component_in_for_no_local_access() {
5398        let mut fragments = HashMap::new();
5399        fragments.insert(
5400            "index.html".to_string(),
5401            FragmentList {
5402                fragments: vec![
5403                    WebUIFragment::raw("<div>"),
5404                    WebUIFragment::for_loop("item", "items", "templateComponent"),
5405                    WebUIFragment::raw("</div>"),
5406                ],
5407            },
5408        );
5409        fragments.insert(
5410            "templateComponent".to_string(),
5411            FragmentList {
5412                fragments: vec![
5413                    WebUIFragment::raw("<component-tag>"),
5414                    WebUIFragment::component("my-component"),
5415                    WebUIFragment::raw("</component-tag>"),
5416                ],
5417            },
5418        );
5419        fragments.insert(
5420            "my-component".to_string(),
5421            FragmentList {
5422                fragments: vec![
5423                    WebUIFragment::raw("<span>"),
5424                    WebUIFragment::signal("name", false),
5425                    WebUIFragment::raw("</span>"),
5426                ],
5427            },
5428        );
5429        let protocol = WebUIProtocol::new(fragments);
5430        let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
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><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
5442        );
5443    }
5444
5445    #[test]
5446    fn test_nested_for_hierarchical_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("globalPrefix", false),
5464                    WebUIFragment::signal("outerItem.outerLabel", false),
5465                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5466                    WebUIFragment::raw("</section>"),
5467                ],
5468            },
5469        );
5470        fragments.insert(
5471            "innerTemplate".to_string(),
5472            FragmentList {
5473                fragments: vec![
5474                    WebUIFragment::raw("<p>"),
5475                    WebUIFragment::signal("globalPrefix", false),
5476                    WebUIFragment::signal("outerItem.outerLabel", false),
5477                    WebUIFragment::raw(": "),
5478                    WebUIFragment::signal("innerItem.innerLabel", false),
5479                    WebUIFragment::raw("</p>"),
5480                ],
5481            },
5482        );
5483        let protocol = WebUIProtocol::new(fragments);
5484        let state = test_json!({
5485            "globalPrefix": "Prefix: ",
5486            "outerItems": [
5487                {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
5488                {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
5489            ]
5490        });
5491        let mut writer = TestWriter::new();
5492        handle(
5493            &protocol,
5494            &state,
5495            &RenderOptions::new("index.html", "/"),
5496            &mut writer,
5497        )
5498        .unwrap();
5499        assert_eq!(
5500            writer.get_content(),
5501            "<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>"
5502        );
5503    }
5504
5505    #[test]
5506    fn test_component_in_for_global_only() {
5507        let mut fragments = HashMap::new();
5508        fragments.insert(
5509            "index.html".to_string(),
5510            FragmentList {
5511                fragments: vec![
5512                    WebUIFragment::raw("<div>"),
5513                    WebUIFragment::for_loop("item", "items", "templateComponent"),
5514                    WebUIFragment::raw("</div>"),
5515                ],
5516            },
5517        );
5518        fragments.insert(
5519            "templateComponent".to_string(),
5520            FragmentList {
5521                fragments: vec![
5522                    WebUIFragment::raw("<component-tag>"),
5523                    WebUIFragment::component("my-component"),
5524                    WebUIFragment::raw("</component-tag>"),
5525                ],
5526            },
5527        );
5528        fragments.insert(
5529            "my-component".to_string(),
5530            FragmentList {
5531                fragments: vec![
5532                    WebUIFragment::raw("<span>"),
5533                    WebUIFragment::signal("name", false),
5534                    WebUIFragment::raw("-"),
5535                    WebUIFragment::signal("globalSuffix", false),
5536                    WebUIFragment::raw("</span>"),
5537                ],
5538            },
5539        );
5540        let protocol = WebUIProtocol::new(fragments);
5541        let state =
5542            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
5543        let mut writer = TestWriter::new();
5544        handle(
5545            &protocol,
5546            &state,
5547            &RenderOptions::new("index.html", "/"),
5548            &mut writer,
5549        )
5550        .unwrap();
5551        assert_eq!(
5552            writer.get_content(),
5553            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
5554        );
5555    }
5556
5557    #[test]
5558    fn test_component_no_item_moniker() {
5559        let mut fragments = HashMap::new();
5560        fragments.insert(
5561            "index.html".to_string(),
5562            FragmentList {
5563                fragments: vec![
5564                    WebUIFragment::raw("<div>"),
5565                    WebUIFragment::for_loop("item", "items", "templateComponent"),
5566                    WebUIFragment::raw("</div>"),
5567                ],
5568            },
5569        );
5570        fragments.insert(
5571            "templateComponent".to_string(),
5572            FragmentList {
5573                fragments: vec![
5574                    WebUIFragment::raw("<component-tag>"),
5575                    WebUIFragment::component("my-component"),
5576                    WebUIFragment::raw("</component-tag>"),
5577                ],
5578            },
5579        );
5580        fragments.insert(
5581            "my-component".to_string(),
5582            FragmentList {
5583                fragments: vec![
5584                    WebUIFragment::raw("<span>"),
5585                    WebUIFragment::signal("item.name", false),
5586                    WebUIFragment::raw("-"),
5587                    WebUIFragment::signal("globalSuffix", false),
5588                    WebUIFragment::raw("</span>"),
5589                ],
5590            },
5591        );
5592        let protocol = WebUIProtocol::new(fragments);
5593        let state =
5594            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
5595        let mut writer = TestWriter::new();
5596        handle(
5597            &protocol,
5598            &state,
5599            &RenderOptions::new("index.html", "/"),
5600            &mut writer,
5601        )
5602        .unwrap();
5603        assert_eq!(
5604            writer.get_content(),
5605            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
5606        );
5607    }
5608
5609    #[test]
5610    fn test_for_nonqualified_uses_global() {
5611        let mut fragments = HashMap::new();
5612        fragments.insert(
5613            "index.html".to_string(),
5614            FragmentList {
5615                fragments: vec![
5616                    WebUIFragment::raw("<div>"),
5617                    WebUIFragment::for_loop("item", "items", "template1"),
5618                    WebUIFragment::raw("</div>"),
5619                ],
5620            },
5621        );
5622        fragments.insert(
5623            "template1".to_string(),
5624            FragmentList {
5625                fragments: vec![
5626                    WebUIFragment::raw("<span>"),
5627                    WebUIFragment::signal("name", false),
5628                    WebUIFragment::raw("</span>"),
5629                ],
5630            },
5631        );
5632        let protocol = WebUIProtocol::new(fragments);
5633        let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
5634        let mut writer = TestWriter::new();
5635        handle(
5636            &protocol,
5637            &state,
5638            &RenderOptions::new("index.html", "/"),
5639            &mut writer,
5640        )
5641        .unwrap();
5642        assert_eq!(
5643            writer.get_content(),
5644            "<div><span>GlobalName</span><span>GlobalName</span></div>"
5645        );
5646    }
5647
5648    #[test]
5649    fn test_nested_for_if_interleaved() {
5650        let mut fragments = HashMap::new();
5651        fragments.insert(
5652            "index.html".to_string(),
5653            FragmentList {
5654                fragments: vec![
5655                    WebUIFragment::raw("<div>"),
5656                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5657                    WebUIFragment::raw("</div>"),
5658                ],
5659            },
5660        );
5661        fragments.insert(
5662            "outerTemplate".to_string(),
5663            FragmentList {
5664                fragments: vec![
5665                    WebUIFragment::raw("<section>"),
5666                    WebUIFragment::signal("globalPrefix", false),
5667                    WebUIFragment::signal("outerItem.outerLabel", false),
5668                    WebUIFragment::if_cond(
5669                        ConditionExpr::identifier("outerItem.include"),
5670                        "ifTemplate",
5671                    ),
5672                    WebUIFragment::raw("</section>"),
5673                ],
5674            },
5675        );
5676        fragments.insert(
5677            "ifTemplate".to_string(),
5678            FragmentList {
5679                fragments: vec![
5680                    WebUIFragment::raw("<div>"),
5681                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5682                    WebUIFragment::raw("</div>"),
5683                ],
5684            },
5685        );
5686        fragments.insert(
5687            "innerTemplate".to_string(),
5688            FragmentList {
5689                fragments: vec![
5690                    WebUIFragment::raw("<p>"),
5691                    WebUIFragment::signal("globalSuffix", false),
5692                    WebUIFragment::raw(": "),
5693                    WebUIFragment::signal("innerItem.innerLabel", false),
5694                    WebUIFragment::raw("</p>"),
5695                ],
5696            },
5697        );
5698        let protocol = WebUIProtocol::new(fragments);
5699        let state = test_json!({
5700            "globalPrefix": "Prefix: ",
5701            "globalSuffix": "Suffix",
5702            "outerItems": [
5703                {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
5704                {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
5705                {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
5706            ]
5707        });
5708        let mut writer = TestWriter::new();
5709        handle(
5710            &protocol,
5711            &state,
5712            &RenderOptions::new("index.html", "/"),
5713            &mut writer,
5714        )
5715        .unwrap();
5716        assert_eq!(
5717            writer.get_content(),
5718            "<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>"
5719        );
5720    }
5721
5722    #[test]
5723    fn test_nested_for_if_outer_state() {
5724        let mut fragments = HashMap::new();
5725        fragments.insert(
5726            "index.html".to_string(),
5727            FragmentList {
5728                fragments: vec![
5729                    WebUIFragment::raw("<div>"),
5730                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5731                    WebUIFragment::raw("</div>"),
5732                ],
5733            },
5734        );
5735        fragments.insert(
5736            "outerTemplate".to_string(),
5737            FragmentList {
5738                fragments: vec![
5739                    WebUIFragment::raw("<section>"),
5740                    WebUIFragment::signal("globalPrefix", false),
5741                    WebUIFragment::signal("outerItem.label", false),
5742                    WebUIFragment::for_loop(
5743                        "middleItem",
5744                        "outerItem.middleItems",
5745                        "middleTemplate",
5746                    ),
5747                    WebUIFragment::raw("</section>"),
5748                ],
5749            },
5750        );
5751        fragments.insert(
5752            "middleTemplate".to_string(),
5753            FragmentList {
5754                fragments: vec![
5755                    WebUIFragment::raw("<div>"),
5756                    WebUIFragment::if_cond(
5757                        ConditionExpr::identifier("outerItem.active"),
5758                        "ifTemplate",
5759                    ),
5760                    WebUIFragment::raw("</div>"),
5761                ],
5762            },
5763        );
5764        fragments.insert(
5765            "ifTemplate".to_string(),
5766            FragmentList {
5767                fragments: vec![
5768                    WebUIFragment::raw("<p>"),
5769                    WebUIFragment::signal("middleItem.value", false),
5770                    WebUIFragment::raw("</p>"),
5771                ],
5772            },
5773        );
5774        let protocol = WebUIProtocol::new(fragments);
5775        let state = test_json!({
5776            "globalPrefix": "GP-",
5777            "outerItems": [
5778                {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
5779                {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
5780                {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
5781            ]
5782        });
5783        let mut writer = TestWriter::new();
5784        handle(
5785            &protocol,
5786            &state,
5787            &RenderOptions::new("index.html", "/"),
5788            &mut writer,
5789        )
5790        .unwrap();
5791        assert_eq!(
5792            writer.get_content(),
5793            "<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>"
5794        );
5795    }
5796
5797    #[test]
5798    fn test_nested_for_if_inner_state() {
5799        let mut fragments = HashMap::new();
5800        fragments.insert(
5801            "index.html".to_string(),
5802            FragmentList {
5803                fragments: vec![
5804                    WebUIFragment::raw("<div>"),
5805                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5806                    WebUIFragment::raw("</div>"),
5807                ],
5808            },
5809        );
5810        fragments.insert(
5811            "outerTemplate".to_string(),
5812            FragmentList {
5813                fragments: vec![
5814                    WebUIFragment::raw("<section>"),
5815                    WebUIFragment::signal("outerItem.label", false),
5816                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5817                    WebUIFragment::raw("</section>"),
5818                ],
5819            },
5820        );
5821        fragments.insert(
5822            "innerTemplate".to_string(),
5823            FragmentList {
5824                fragments: vec![
5825                    WebUIFragment::raw("<article>"),
5826                    WebUIFragment::if_cond(
5827                        ConditionExpr::identifier("innerItem.show"),
5828                        "ifTemplate",
5829                    ),
5830                    WebUIFragment::raw("</article>"),
5831                ],
5832            },
5833        );
5834        fragments.insert(
5835            "ifTemplate".to_string(),
5836            FragmentList {
5837                fragments: vec![
5838                    WebUIFragment::raw("<p>"),
5839                    WebUIFragment::signal("innerItem.detail", false),
5840                    WebUIFragment::raw("</p>"),
5841                ],
5842            },
5843        );
5844        let protocol = WebUIProtocol::new(fragments);
5845        let state = test_json!({
5846            "outerItems": [
5847                {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
5848                {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
5849            ]
5850        });
5851        let mut writer = TestWriter::new();
5852        handle(
5853            &protocol,
5854            &state,
5855            &RenderOptions::new("index.html", "/"),
5856            &mut writer,
5857        )
5858        .unwrap();
5859        assert_eq!(
5860            writer.get_content(),
5861            "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
5862        );
5863    }
5864
5865    #[test]
5866    fn test_for_merge_local_global_monikers() {
5867        let mut fragments = HashMap::new();
5868        fragments.insert(
5869            "index.html".to_string(),
5870            FragmentList {
5871                fragments: vec![
5872                    WebUIFragment::raw("<div>"),
5873                    WebUIFragment::for_loop("item", "items", "template1"),
5874                    WebUIFragment::raw("</div>"),
5875                ],
5876            },
5877        );
5878        fragments.insert(
5879            "template1".to_string(),
5880            FragmentList {
5881                fragments: vec![
5882                    WebUIFragment::raw("<span>"),
5883                    WebUIFragment::signal("item.name", false),
5884                    WebUIFragment::raw("-"),
5885                    WebUIFragment::signal("item.globalValue", false),
5886                    WebUIFragment::raw("-"),
5887                    WebUIFragment::signal("item.localOnly", false),
5888                    WebUIFragment::raw("-"),
5889                    WebUIFragment::signal("item.otherVal", false),
5890                    WebUIFragment::raw("</span>"),
5891                ],
5892            },
5893        );
5894        let protocol = WebUIProtocol::new(fragments);
5895        let state = test_json!({
5896            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
5897            "items": [
5898                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
5899                {"name": "Local2", "localOnly": "Only2"}
5900            ]
5901        });
5902        let mut writer = TestWriter::new();
5903        handle(
5904            &protocol,
5905            &state,
5906            &RenderOptions::new("index.html", "/"),
5907            &mut writer,
5908        )
5909        .unwrap();
5910        assert_eq!(
5911            writer.get_content(),
5912            "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
5913        );
5914    }
5915
5916    #[test]
5917    fn test_component_in_for_global_moniker_shadow() {
5918        let mut fragments = HashMap::new();
5919        fragments.insert(
5920            "index.html".to_string(),
5921            FragmentList {
5922                fragments: vec![
5923                    WebUIFragment::raw("<div>"),
5924                    WebUIFragment::for_loop("item", "items", "templateComponent"),
5925                    WebUIFragment::raw("</div>"),
5926                ],
5927            },
5928        );
5929        fragments.insert(
5930            "templateComponent".to_string(),
5931            FragmentList {
5932                fragments: vec![
5933                    WebUIFragment::raw("<component-tag>"),
5934                    WebUIFragment::component("my-component"),
5935                    WebUIFragment::raw("</component-tag>"),
5936                ],
5937            },
5938        );
5939        fragments.insert(
5940            "my-component".to_string(),
5941            FragmentList {
5942                fragments: vec![
5943                    WebUIFragment::raw("<span>"),
5944                    WebUIFragment::signal("name", false),
5945                    WebUIFragment::raw("-"),
5946                    WebUIFragment::signal("item.globalValue", false),
5947                    WebUIFragment::raw("-"),
5948                    WebUIFragment::signal("localOnly", false),
5949                    WebUIFragment::raw("-"),
5950                    WebUIFragment::signal("item.otherVal", false),
5951                    WebUIFragment::raw("</span>"),
5952                ],
5953            },
5954        );
5955        let protocol = WebUIProtocol::new(fragments);
5956        let state = test_json!({
5957            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
5958            "items": [
5959                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
5960                {"name": "Local2", "localOnly": "Only2"}
5961            ]
5962        });
5963        let mut writer = TestWriter::new();
5964        handle(
5965            &protocol,
5966            &state,
5967            &RenderOptions::new("index.html", "/"),
5968            &mut writer,
5969        )
5970        .unwrap();
5971        assert_eq!(
5972            writer.get_content(),
5973            "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
5974        );
5975    }
5976
5977    #[test]
5978    fn test_if_in_nested_for_local_flag() {
5979        let mut fragments = HashMap::new();
5980        fragments.insert(
5981            "index.html".to_string(),
5982            FragmentList {
5983                fragments: vec![
5984                    WebUIFragment::raw("<div>"),
5985                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
5986                    WebUIFragment::raw("</div>"),
5987                ],
5988            },
5989        );
5990        fragments.insert(
5991            "outerTemplate".to_string(),
5992            FragmentList {
5993                fragments: vec![
5994                    WebUIFragment::raw("<section>"),
5995                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
5996                    WebUIFragment::raw("</section>"),
5997                ],
5998            },
5999        );
6000        fragments.insert(
6001            "innerTemplate".to_string(),
6002            FragmentList {
6003                fragments: vec![WebUIFragment::if_cond(
6004                    ConditionExpr::identifier("inner_item.flag"),
6005                    "ifInner",
6006                )],
6007            },
6008        );
6009        fragments.insert(
6010            "ifInner".to_string(),
6011            FragmentList {
6012                fragments: vec![
6013                    WebUIFragment::raw("<p>"),
6014                    WebUIFragment::signal("inner_item.value", false),
6015                    WebUIFragment::raw("</p>"),
6016                ],
6017            },
6018        );
6019        let protocol = WebUIProtocol::new(fragments);
6020        let state = test_json!({
6021            "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
6022            "inner_item": {"flag": false}
6023        });
6024        let mut writer = TestWriter::new();
6025        handle(
6026            &protocol,
6027            &state,
6028            &RenderOptions::new("index.html", "/"),
6029            &mut writer,
6030        )
6031        .unwrap();
6032        assert_eq!(
6033            writer.get_content(),
6034            "<div><section><p>X</p></section></div>"
6035        );
6036    }
6037
6038    #[test]
6039    fn test_if_in_nested_for_global_fallback() {
6040        let mut fragments = HashMap::new();
6041        fragments.insert(
6042            "index.html".to_string(),
6043            FragmentList {
6044                fragments: vec![
6045                    WebUIFragment::raw("<div>"),
6046                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
6047                    WebUIFragment::raw("</div>"),
6048                ],
6049            },
6050        );
6051        fragments.insert(
6052            "outerTemplate".to_string(),
6053            FragmentList {
6054                fragments: vec![
6055                    WebUIFragment::raw("<section>"),
6056                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
6057                    WebUIFragment::raw("</section>"),
6058                ],
6059            },
6060        );
6061        fragments.insert(
6062            "innerTemplate".to_string(),
6063            FragmentList {
6064                fragments: vec![WebUIFragment::if_cond(
6065                    ConditionExpr::identifier("inner_item.flag"),
6066                    "ifInner",
6067                )],
6068            },
6069        );
6070        fragments.insert(
6071            "ifInner".to_string(),
6072            FragmentList {
6073                fragments: vec![
6074                    WebUIFragment::raw("<p>"),
6075                    WebUIFragment::signal("inner_item.value", false),
6076                    WebUIFragment::raw("</p>"),
6077                ],
6078            },
6079        );
6080        let protocol = WebUIProtocol::new(fragments);
6081        let state = test_json!({
6082            "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
6083            "inner_item": {"flag": true}
6084        });
6085        let mut writer = TestWriter::new();
6086        handle(
6087            &protocol,
6088            &state,
6089            &RenderOptions::new("index.html", "/"),
6090            &mut writer,
6091        )
6092        .unwrap();
6093        assert_eq!(
6094            writer.get_content(),
6095            "<div><section><p>X</p><p>Y</p></section></div>"
6096        );
6097    }
6098
6099    #[test]
6100    fn test_if_mixed_for_monikers() {
6101        let mut fragments = HashMap::new();
6102        fragments.insert(
6103            "index.html".to_string(),
6104            FragmentList {
6105                fragments: vec![
6106                    WebUIFragment::raw("<div>"),
6107                    WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
6108                    WebUIFragment::raw("</div>"),
6109                ],
6110            },
6111        );
6112        fragments.insert(
6113            "outerTemplate".to_string(),
6114            FragmentList {
6115                fragments: vec![
6116                    WebUIFragment::raw("<section>"),
6117                    WebUIFragment::signal("outer.outerLabel", false),
6118                    WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
6119                    WebUIFragment::raw("</section>"),
6120                ],
6121            },
6122        );
6123        fragments.insert(
6124            "innerTemplate".to_string(),
6125            FragmentList {
6126                fragments: vec![WebUIFragment::if_cond(
6127                    ConditionExpr::compound(
6128                        ConditionExpr::identifier("outer.active"),
6129                        LogicalOperator::And,
6130                        ConditionExpr::predicate(
6131                            "inner.value",
6132                            ComparisonOperator::GreaterThan,
6133                            "globalLimit",
6134                        ),
6135                    ),
6136                    "ifInner",
6137                )],
6138            },
6139        );
6140        fragments.insert(
6141            "ifInner".to_string(),
6142            FragmentList {
6143                fragments: vec![
6144                    WebUIFragment::raw("<p>"),
6145                    WebUIFragment::signal("inner.value", false),
6146                    WebUIFragment::raw("</p>"),
6147                ],
6148            },
6149        );
6150        let protocol = WebUIProtocol::new(fragments);
6151        let state = test_json!({
6152            "globalLimit": 10,
6153            "list": {"outerItems": [
6154                {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
6155                {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
6156                {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
6157            ]}
6158        });
6159        let mut writer = TestWriter::new();
6160        handle(
6161            &protocol,
6162            &state,
6163            &RenderOptions::new("index.html", "/"),
6164            &mut writer,
6165        )
6166        .unwrap();
6167        assert_eq!(
6168            writer.get_content(),
6169            "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
6170        );
6171    }
6172
6173    // ── Route-aware rendering tests ─────────────────────────────────────
6174
6175    fn make_route_protocol() -> WebUIProtocol {
6176        use webui_protocol::WebUiFragmentRoute;
6177
6178        let mut fragments = HashMap::new();
6179
6180        // Entry page with two routes
6181        fragments.insert(
6182            "index.html".to_string(),
6183            FragmentList {
6184                fragments: vec![
6185                    WebUIFragment::raw("<h1>Shell</h1>"),
6186                    WebUIFragment::route_from(WebUiFragmentRoute {
6187                        path: "/".into(),
6188                        fragment_id: "dash-page".into(),
6189                        exact: true,
6190                        keep_alive: false,
6191                        ..Default::default()
6192                    }),
6193                    WebUIFragment::route_from(WebUiFragmentRoute {
6194                        path: "/contacts/:id".into(),
6195                        fragment_id: "detail-page".into(),
6196                        exact: true,
6197                        keep_alive: false,
6198                        ..Default::default()
6199                    }),
6200                ],
6201            },
6202        );
6203
6204        // Dashboard page component
6205        fragments.insert(
6206            "dash-page".to_string(),
6207            FragmentList {
6208                fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
6209            },
6210        );
6211
6212        // Detail page component
6213        fragments.insert(
6214            "detail-page".to_string(),
6215            FragmentList {
6216                fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
6217            },
6218        );
6219
6220        WebUIProtocol::new(fragments)
6221    }
6222
6223    fn make_nested_route_protocol() -> WebUIProtocol {
6224        use webui_protocol::WebUiFragmentRoute;
6225
6226        let mut fragments = HashMap::new();
6227
6228        fragments.insert(
6229            "index.html".to_string(),
6230            FragmentList {
6231                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
6232                    path: "/".into(),
6233                    fragment_id: "app-shell".into(),
6234                    exact: false,
6235                    children: vec![WebUiFragmentRoute {
6236                        path: "sections/:id".into(),
6237                        fragment_id: "section-comp".into(),
6238                        exact: false,
6239                        children: vec![WebUiFragmentRoute {
6240                            path: "topics/:topicId".into(),
6241                            fragment_id: "topic-comp".into(),
6242                            exact: true,
6243                            children: vec![],
6244                            keep_alive: false,
6245                            ..Default::default()
6246                        }],
6247                        keep_alive: false,
6248                        ..Default::default()
6249                    }],
6250                    keep_alive: false,
6251                    ..Default::default()
6252                })],
6253            },
6254        );
6255
6256        fragments.insert(
6257            "app-shell".to_string(),
6258            FragmentList {
6259                fragments: vec![
6260                    WebUIFragment::raw("<h1>Shell</h1>"),
6261                    WebUIFragment::outlet(),
6262                ],
6263            },
6264        );
6265
6266        fragments.insert(
6267            "section-comp".to_string(),
6268            FragmentList {
6269                fragments: vec![
6270                    WebUIFragment::raw("<h2>Section</h2>"),
6271                    WebUIFragment::outlet(),
6272                ],
6273            },
6274        );
6275
6276        fragments.insert(
6277            "topic-comp".to_string(),
6278            FragmentList {
6279                fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
6280            },
6281        );
6282
6283        WebUIProtocol::new(fragments)
6284    }
6285
6286    #[test]
6287    fn test_route_renders_shell_always() {
6288        let protocol = make_route_protocol();
6289        let state = test_json!({});
6290        let mut writer = TestWriter::new();
6291        handle(
6292            &protocol,
6293            &state,
6294            &RenderOptions::new("index.html", "/"),
6295            &mut writer,
6296        )
6297        .unwrap();
6298        let html = writer.get_content();
6299
6300        // Shell content always renders regardless of route matching
6301        assert!(html.contains("<h1>Shell</h1>"), "shell should render");
6302        // Dashboard matches "/" so it should be active
6303        assert!(html.contains(" active>"), "matched route should be active");
6304        // Detail should be hidden and empty
6305        assert!(
6306            html.contains("style=\"display:none\""),
6307            "non-matched routes should be hidden"
6308        );
6309    }
6310
6311    #[test]
6312    fn test_route_matched_renders_visible() {
6313        let protocol = make_route_protocol();
6314        let state = test_json!({});
6315        let mut writer = TestWriter::new();
6316        handle(
6317            &protocol,
6318            &state,
6319            &RenderOptions::new("index.html", "/"),
6320            &mut writer,
6321        )
6322        .unwrap();
6323        let html = writer.get_content();
6324
6325        // Dashboard route should be visible (active, no display:none)
6326        assert!(
6327            html.contains("<webui-route path=\"/\""),
6328            "dashboard route should exist"
6329        );
6330        assert!(
6331            html.contains("active>") && html.contains("<dash-page>"),
6332            "matched route should be active with component tag: {html}"
6333        );
6334        assert!(
6335            html.contains("<p>Dashboard</p>"),
6336            "matched route should have content"
6337        );
6338    }
6339
6340    #[test]
6341    fn test_route_non_matched_renders_hidden_empty() {
6342        let protocol = make_route_protocol();
6343        let state = test_json!({});
6344        let mut writer = TestWriter::new();
6345        handle(
6346            &protocol,
6347            &state,
6348            &RenderOptions::new("index.html", "/"),
6349            &mut writer,
6350        )
6351        .unwrap();
6352        let html = writer.get_content();
6353
6354        // Detail route should be hidden and empty (no content rendered)
6355        assert!(
6356            html.contains("<webui-route path=\"/contacts/:id\""),
6357            "detail route element should exist"
6358        );
6359        // The non-matched route should have display:none and no inner content
6360        let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
6361        let after_detail = &html[detail_start..];
6362        assert!(
6363            after_detail.contains("style=\"display:none\">")
6364                && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
6365            "non-matched route should be hidden: {after_detail}"
6366        );
6367        // Should NOT contain the component's rendered content
6368        let detail_end = after_detail.find("</webui-route>").expect("closing tag");
6369        let detail_body = &after_detail[..detail_end];
6370        assert!(
6371            !detail_body.contains("<detail-page>"),
6372            "non-matched route should not render component content: {detail_body}"
6373        );
6374    }
6375
6376    #[test]
6377    fn test_route_parameterized_match() {
6378        let protocol = make_route_protocol();
6379        let state = test_json!({});
6380        let mut writer = TestWriter::new();
6381        handle(
6382            &protocol,
6383            &state,
6384            &RenderOptions::new("index.html", "/contacts/42"),
6385            &mut writer,
6386        )
6387        .unwrap();
6388        let html = writer.get_content();
6389
6390        // Detail route matches /contacts/42
6391        assert!(
6392            html.contains("active>") && html.contains("<detail-page>"),
6393            "detail route should be active: {html}"
6394        );
6395        assert!(html.contains("<p>Detail</p>"), "detail should have content");
6396        // Dashboard should be hidden + empty
6397        let dash_start = html
6398            .find("component=\"dash-page\"")
6399            .expect("dashboard route");
6400        let after_dash = &html[dash_start..];
6401        assert!(
6402            after_dash.contains("style=\"display:none\">"),
6403            "dashboard should be hidden when detail matches: {after_dash}"
6404        );
6405        let dash_end = after_dash.find("</webui-route>").expect("closing tag");
6406        let dash_body = &after_dash[..dash_end];
6407        assert!(
6408            !dash_body.contains("<dash-page>"),
6409            "dashboard should not render component content: {dash_body}"
6410        );
6411    }
6412
6413    #[test]
6414    fn test_route_no_match_all_hidden_empty() {
6415        let protocol = make_route_protocol();
6416        let state = test_json!({});
6417        let mut writer = TestWriter::new();
6418        handle(
6419            &protocol,
6420            &state,
6421            &RenderOptions::new("index.html", "/nonexistent"),
6422            &mut writer,
6423        )
6424        .unwrap();
6425        let html = writer.get_content();
6426
6427        // Shell content should still render
6428        assert!(html.contains("<h1>Shell</h1>"));
6429        // All routes should be hidden + empty (nothing matched)
6430        assert!(
6431            !html.contains("<p>Dashboard</p>"),
6432            "no route content when nothing matches"
6433        );
6434        assert!(
6435            !html.contains("<p>Detail</p>"),
6436            "no route content when nothing matches"
6437        );
6438    }
6439
6440    #[test]
6441    fn test_route_component_attr_emitted() {
6442        let protocol = make_route_protocol();
6443        let state = test_json!({});
6444        let mut writer = TestWriter::new();
6445        handle(
6446            &protocol,
6447            &state,
6448            &RenderOptions::new("index.html", "/"),
6449            &mut writer,
6450        )
6451        .unwrap();
6452        let html = writer.get_content();
6453        // component attribute should be emitted on webui-route
6454        assert!(
6455            html.contains("component=\"dash-page\""),
6456            "component attr should be on webui-route: {html}"
6457        );
6458        assert!(
6459            html.contains("component=\"detail-page\""),
6460            "component attr should be on webui-route: {html}"
6461        );
6462    }
6463
6464    #[test]
6465    fn test_no_plugin_no_state_attributes() {
6466        let protocol = make_route_protocol();
6467        let state = test_json!({
6468            "title": "Fish & Chips",
6469            "cartOpen": true,
6470            "items": [{"name": "A&B"}]
6471        });
6472        let mut writer = TestWriter::new();
6473
6474        handle(
6475            &protocol,
6476            &state,
6477            &RenderOptions::new("index.html", "/"),
6478            &mut writer,
6479        )
6480        .unwrap();
6481
6482        let html = writer.get_content();
6483        // Without a plugin, no state attributes at all
6484        assert!(
6485            !html.contains("data-state"),
6486            "no data-state without plugin: {html}"
6487        );
6488        assert!(
6489            !html.contains(r#"title="Fish"#),
6490            "no scalar attrs without plugin: {html}"
6491        );
6492    }
6493
6494    #[test]
6495    fn test_nested_routes_render_webui_route_as_light_dom() {
6496        let protocol = make_nested_route_protocol();
6497        let state = test_json!({"title": "Test"});
6498        let handler = WebUIHandler::new();
6499        let mut writer = TestWriter::new();
6500
6501        handler
6502            .handle(
6503                &protocol,
6504                &state,
6505                &RenderOptions::new("index.html", "/sections/frontend"),
6506                &mut writer,
6507            )
6508            .expect("render failed");
6509
6510        let html = writer.get_content();
6511
6512        assert!(
6513            html.contains("component=\"app-shell\"") && html.contains("active>"),
6514            "root route should be active: {html}"
6515        );
6516        // webui-route should NOT have shadow DOM — it's a light DOM structural element
6517        assert!(
6518            !html.contains("<template shadowrootmode"),
6519            "webui-route should be light DOM (no shadow template): {html}"
6520        );
6521    }
6522
6523    #[test]
6524    fn test_nested_routes_render_outlet_as_light_dom() {
6525        let protocol = make_nested_route_protocol();
6526        let state = test_json!({"title": "Test"});
6527        let handler = WebUIHandler::new();
6528        let mut writer = TestWriter::new();
6529
6530        handler
6531            .handle(
6532                &protocol,
6533                &state,
6534                &RenderOptions::new("index.html", "/sections/frontend"),
6535                &mut writer,
6536            )
6537            .expect("render failed");
6538
6539        let html = writer.get_content();
6540
6541        // No <webui-outlet> wrapper — routes render directly at outlet position
6542        assert!(
6543            !html.contains("<webui-outlet>"),
6544            "should not contain webui-outlet wrapper: {html}"
6545        );
6546        // Route elements should be in the output directly
6547        assert!(
6548            html.contains("<webui-route"),
6549            "should contain webui-route elements: {html}"
6550        );
6551    }
6552
6553    #[test]
6554    fn test_nested_routes_match_child_at_outlet() {
6555        let protocol = make_nested_route_protocol();
6556        let state = test_json!({"title": "Test"});
6557        let handler = WebUIHandler::new();
6558        let mut writer = TestWriter::new();
6559
6560        handler
6561            .handle(
6562                &protocol,
6563                &state,
6564                &RenderOptions::new("index.html", "/sections/frontend"),
6565                &mut writer,
6566            )
6567            .expect("render failed");
6568
6569        let html = writer.get_content();
6570
6571        assert!(
6572            html.contains("component=\"section-comp\"") && html.contains("active>"),
6573            "section route should be active: {html}"
6574        );
6575        assert!(
6576            html.contains("<h2>Section</h2>"),
6577            "section content should be present: {html}"
6578        );
6579    }
6580
6581    #[test]
6582    fn test_nested_routes_three_levels_deep() {
6583        let protocol = make_nested_route_protocol();
6584        let state = test_json!({"title": "Test"});
6585        let handler = WebUIHandler::new();
6586        let mut writer = TestWriter::new();
6587
6588        handler
6589            .handle(
6590                &protocol,
6591                &state,
6592                &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
6593                &mut writer,
6594            )
6595            .expect("render failed");
6596
6597        let html = writer.get_content();
6598
6599        assert!(
6600            html.contains("component=\"app-shell\"") && html.contains("active>"),
6601            "root active: {html}"
6602        );
6603        assert!(
6604            html.contains("component=\"section-comp\"") && html.contains("active>"),
6605            "section active: {html}"
6606        );
6607        assert!(
6608            html.contains("component=\"topic-comp\"")
6609                && html.contains("exact")
6610                && html.contains("active>"),
6611            "topic active: {html}"
6612        );
6613        assert!(
6614            html.contains("<p>Topic content</p>"),
6615            "leaf content present: {html}"
6616        );
6617    }
6618
6619    #[test]
6620    fn test_nested_routes_nonmatched_siblings_hidden() {
6621        let protocol = make_nested_route_protocol();
6622        let state = test_json!({"title": "Test"});
6623        let handler = WebUIHandler::new();
6624        let mut writer = TestWriter::new();
6625
6626        handler
6627            .handle(
6628                &protocol,
6629                &state,
6630                &RenderOptions::new("index.html", "/sections/frontend"),
6631                &mut writer,
6632            )
6633            .expect("render failed");
6634
6635        let html = writer.get_content();
6636
6637        assert!(
6638            html.contains(r#"component="topic-comp" exact style="display:none">"#),
6639            "topic should be hidden: {html}"
6640        );
6641    }
6642
6643    #[test]
6644    fn test_nested_routes_root_only() {
6645        let protocol = make_nested_route_protocol();
6646        let state = test_json!({"title": "Test"});
6647        let handler = WebUIHandler::new();
6648        let mut writer = TestWriter::new();
6649
6650        handler
6651            .handle(
6652                &protocol,
6653                &state,
6654                &RenderOptions::new("index.html", "/"),
6655                &mut writer,
6656            )
6657            .expect("render failed");
6658
6659        let html = writer.get_content();
6660
6661        assert!(
6662            html.contains("component=\"app-shell\"") && html.contains("active>"),
6663            "root active at /: {html}"
6664        );
6665        assert!(
6666            html.contains("<h1>Shell</h1>"),
6667            "shell renders at /: {html}"
6668        );
6669        assert!(
6670            html.contains(r#"component="section-comp" style="display:none">"#),
6671            "section hidden at /: {html}"
6672        );
6673    }
6674
6675    // ── CSS Module dedup tests ───────────────────────────────────────
6676
6677    #[test]
6678    fn test_css_module_emitted_once_inline_in_component() {
6679        // CSS module definition emitted once in the component's light DOM
6680        // on first render, not in <head> and not on second instance.
6681        let template = r#"<p><slot></slot></p>"#;
6682
6683        let mut fragments = HashMap::new();
6684        fragments.insert(
6685            "index.html".to_string(),
6686            FragmentList {
6687                fragments: vec![
6688                    WebUIFragment::raw("<html><head>".to_string()),
6689                    structural_fragment("head_end"),
6690                    WebUIFragment::raw("</head><body><div>".to_string()),
6691                    WebUIFragment::component("my-card"),
6692                    WebUIFragment::raw("A".to_string()),
6693                    WebUIFragment::component("my-card"),
6694                    WebUIFragment::raw("B</div>".to_string()),
6695                    structural_fragment("body_end"),
6696                    WebUIFragment::raw("</body></html>".to_string()),
6697                ],
6698            },
6699        );
6700        fragments.insert(
6701            "my-card".to_string(),
6702            FragmentList {
6703                fragments: vec![WebUIFragment::raw(template.to_string())],
6704            },
6705        );
6706
6707        let mut protocol = WebUIProtocol::new(fragments);
6708        protocol
6709            .components
6710            .entry("my-card".to_string())
6711            .or_default()
6712            .css = "p{color:red}".to_string();
6713        let state = test_json!({});
6714        let mut writer = TestWriter::new();
6715
6716        handle(
6717            &protocol,
6718            &state,
6719            &RenderOptions::new("index.html", "/"),
6720            &mut writer,
6721        )
6722        .unwrap();
6723
6724        let html = writer.get_content();
6725
6726        // CSS module importmap should appear exactly once
6727        let count = html.matches(r#"<script type="importmap""#).count();
6728        assert_eq!(
6729            count, 1,
6730            "CSS module importmap should be emitted once, got {count} in: {html}"
6731        );
6732        assert!(
6733            html.contains(r#""my-card":"data:text/css,"#),
6734            "Importmap must register my-card under a data: URI: {html}"
6735        );
6736
6737        // Template content should appear twice (once per component instance)
6738        let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
6739        assert_eq!(
6740            tmpl_count, 2,
6741            "Template should render twice, got {tmpl_count} in: {html}"
6742        );
6743
6744        // CSS module should be in <body> (inline), not in <head>
6745        let css_pos = html
6746            .find(r#"<script type="importmap""#)
6747            .expect("CSS module importmap missing");
6748        let body_pos = html.find("<body>").expect("<body> missing");
6749        assert!(
6750            css_pos > body_pos,
6751            "CSS module should be inline in component, not in <head>: {html}"
6752        );
6753    }
6754
6755    #[test]
6756    fn test_component_without_css_renders_normally() {
6757        // Components without CSS module prefix pass through unchanged
6758        let mut fragments = HashMap::new();
6759        fragments.insert(
6760            "index.html".to_string(),
6761            FragmentList {
6762                fragments: vec![WebUIFragment::component("my-card")],
6763            },
6764        );
6765        fragments.insert(
6766            "my-card".to_string(),
6767            FragmentList {
6768                fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
6769            },
6770        );
6771
6772        let protocol = WebUIProtocol::new(fragments);
6773        let state = test_json!({});
6774        let mut writer = TestWriter::new();
6775
6776        handle(
6777            &protocol,
6778            &state,
6779            &RenderOptions::new("index.html", "/"),
6780            &mut writer,
6781        )
6782        .unwrap();
6783
6784        let html = writer.get_content();
6785        assert!(
6786            html.contains("<p>hello</p>"),
6787            "Non-module component should render normally: {html}"
6788        );
6789    }
6790
6791    #[test]
6792    fn test_non_module_strategy_no_css_in_head() {
6793        // When component_css is empty (Link/Style strategies), no
6794        // CSS module importmap tags should appear in <head>.
6795        let template = r#"<p>hello</p>"#;
6796
6797        let mut fragments = HashMap::new();
6798        fragments.insert(
6799            "index.html".to_string(),
6800            FragmentList {
6801                fragments: vec![
6802                    WebUIFragment::raw("<html><head>".to_string()),
6803                    structural_fragment("head_end"),
6804                    WebUIFragment::raw("</head><body>".to_string()),
6805                    WebUIFragment::component("my-card"),
6806                    WebUIFragment::raw("</body></html>".to_string()),
6807                ],
6808            },
6809        );
6810        fragments.insert(
6811            "my-card".to_string(),
6812            FragmentList {
6813                fragments: vec![WebUIFragment::raw(template.to_string())],
6814            },
6815        );
6816
6817        // No component css populated — simulates Link/Style strategy
6818        let protocol = WebUIProtocol::new(fragments);
6819        let state = test_json!({});
6820        let mut writer = TestWriter::new();
6821
6822        handle(
6823            &protocol,
6824            &state,
6825            &RenderOptions::new("index.html", "/"),
6826            &mut writer,
6827        )
6828        .unwrap();
6829
6830        let html = writer.get_content();
6831
6832        assert!(
6833            !html.contains(r#"<style type="module""#),
6834            "Non-module strategy should not emit legacy CSS module tags in <head>: {html}"
6835        );
6836        assert!(
6837            !html.contains(r#"<script type="importmap""#),
6838            "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
6839        );
6840        assert!(
6841            html.contains("<p>hello</p>"),
6842            "Component should still render: {html}"
6843        );
6844    }
6845
6846    #[test]
6847    fn test_style_strategy_embeds_inline_style_in_shadow_template() {
6848        let mut fragments = HashMap::new();
6849        fragments.insert(
6850            "index.html".to_string(),
6851            FragmentList {
6852                fragments: vec![
6853                    WebUIFragment::raw("<html><head>".to_string()),
6854                    structural_fragment("head_end"),
6855                    WebUIFragment::raw("</head><body><my-card>".to_string()),
6856                    WebUIFragment::component("my-card"),
6857                    WebUIFragment::raw("</my-card></body></html>".to_string()),
6858                ],
6859            },
6860        );
6861        fragments.insert(
6862            "my-card".to_string(),
6863            FragmentList {
6864                fragments: vec![WebUIFragment::raw(
6865                    "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
6866                        .to_string(),
6867                )],
6868            },
6869        );
6870
6871        let protocol = WebUIProtocol::new(fragments);
6872        let state = test_json!({});
6873        let mut writer = TestWriter::new();
6874
6875        handle(
6876            &protocol,
6877            &state,
6878            &RenderOptions::new("index.html", "/"),
6879            &mut writer,
6880        )
6881        .unwrap();
6882
6883        let html = writer.get_content();
6884
6885        assert!(
6886            html.contains("<style>.card{color:red}</style>"),
6887            "Style strategy should embed inline CSS in shadow template: {html}"
6888        );
6889        assert!(
6890            !html.contains(r#"<style type="module""#),
6891            "Style strategy should not emit legacy module CSS in <head>: {html}"
6892        );
6893        assert!(
6894            !html.contains(r#"<script type="importmap""#),
6895            "Style strategy should not emit CSS module importmaps in <head>: {html}"
6896        );
6897    }
6898
6899    #[test]
6900    fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
6901        // Light DOM + Link strategy: handler emits <link rel="stylesheet">
6902        // in <head>. No preload tag — the stylesheet itself fetches.
6903        let mut fragments = HashMap::new();
6904        fragments.insert(
6905            "index.html".to_string(),
6906            FragmentList {
6907                fragments: vec![
6908                    WebUIFragment::raw("<html><head>".to_string()),
6909                    structural_fragment("head_end"),
6910                    WebUIFragment::raw("</head><body><my-card>".to_string()),
6911                    WebUIFragment::component("my-card"),
6912                    WebUIFragment::raw("</my-card>".to_string()),
6913                    structural_fragment("body_end"),
6914                    WebUIFragment::raw("</body></html>".to_string()),
6915                ],
6916            },
6917        );
6918        fragments.insert(
6919            "my-card".to_string(),
6920            FragmentList {
6921                fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
6922            },
6923        );
6924
6925        let mut protocol = WebUIProtocol::new(fragments);
6926        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
6927        protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
6928
6929        let comp = protocol
6930            .components
6931            .entry("my-card".to_string())
6932            .or_default();
6933        comp.css_href = "my-card.css".to_string();
6934        comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
6935
6936        let state = test_json!({});
6937        let mut writer = TestWriter::new();
6938
6939        handle(
6940            &protocol,
6941            &state,
6942            &RenderOptions::new("index.html", "/"),
6943            &mut writer,
6944        )
6945        .unwrap();
6946
6947        let html = writer.get_content();
6948
6949        let head_end = html.find("</head>").expect("</head> missing");
6950        let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css">"#);
6951        assert!(
6952            link_pos.is_some_and(|p| p < head_end),
6953            "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
6954        );
6955        assert!(
6956            !html.contains(r#"<link rel="preload""#),
6957            "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
6958        );
6959        assert!(
6960            !html.contains(r#"<style type="module""#),
6961            "Link strategy should not emit legacy module CSS: {html}"
6962        );
6963        assert!(
6964            !html.contains(r#"<script type="importmap""#),
6965            "Link strategy should not emit CSS module importmaps: {html}"
6966        );
6967    }
6968
6969    #[test]
6970    fn test_link_strategy_shadow_dom_emits_preload_in_head() {
6971        // Shadow DOM + Link strategy: handler emits <link rel="preload">
6972        // with data-webui-ssr-preload in <head>. No stylesheet — the shadow
6973        // root template already contains <link rel="stylesheet">.
6974        let mut fragments = HashMap::new();
6975        fragments.insert(
6976            "index.html".to_string(),
6977            FragmentList {
6978                fragments: vec![
6979                    WebUIFragment::raw("<html><head>".to_string()),
6980                    structural_fragment("head_end"),
6981                    WebUIFragment::raw("</head><body><o-loading-state>".to_string()),
6982                    WebUIFragment::component("o-loading-state"),
6983                    WebUIFragment::raw("</o-loading-state><my-card>".to_string()),
6984                    WebUIFragment::component("my-card"),
6985                    WebUIFragment::raw("</my-card>".to_string()),
6986                    structural_fragment("body_end"),
6987                    WebUIFragment::raw("</body></html>".to_string()),
6988                ],
6989            },
6990        );
6991        fragments.insert(
6992            "o-loading-state".to_string(),
6993            FragmentList {
6994                fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
6995            },
6996        );
6997        fragments.insert(
6998            "my-card".to_string(),
6999            FragmentList {
7000                fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
7001            },
7002        );
7003
7004        let mut protocol = WebUIProtocol::new(fragments);
7005        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
7006        protocol.set_dom_strategy(webui_protocol::DomStrategy::Shadow);
7007
7008        let comp1 = protocol
7009            .components
7010            .entry("o-loading-state".to_string())
7011            .or_default();
7012        comp1.css_href = "o-loading-state.css".to_string();
7013        comp1.template_json = r#"{"h":"<div>loading</div>"}"#.to_string();
7014
7015        let comp2 = protocol
7016            .components
7017            .entry("my-card".to_string())
7018            .or_default();
7019        comp2.css_href = "my-card.css".to_string();
7020        comp2.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
7021
7022        let state = test_json!({});
7023        let mut writer = TestWriter::new();
7024
7025        handle(
7026            &protocol,
7027            &state,
7028            &RenderOptions::new("index.html", "/"),
7029            &mut writer,
7030        )
7031        .unwrap();
7032
7033        let html = writer.get_content();
7034        let head_end = html.find("</head>").expect("</head> missing");
7035        let head_section = &html[..head_end];
7036
7037        // Both preload hints must be present with data-webui-ssr-preload attr
7038        assert!(
7039            head_section.contains(
7040                r#"<link rel="preload" href="o-loading-state.css" as="style" data-webui-ssr-preload="style">"#
7041            ),
7042            "Missing preload for o-loading-state.css in <head>: {html}"
7043        );
7044        assert!(
7045            head_section.contains(
7046                r#"<link rel="preload" href="my-card.css" as="style" data-webui-ssr-preload="style">"#
7047            ),
7048            "Missing preload for my-card.css in <head>: {html}"
7049        );
7050        // No stylesheet links — shadow root handles that
7051        assert!(
7052            !head_section.contains(r#"<link rel="stylesheet""#),
7053            "Shadow DOM should NOT emit <link rel=stylesheet> in <head>: {html}"
7054        );
7055    }
7056
7057    #[test]
7058    fn test_module_preloads_emit_in_head_in_compiler_order() {
7059        // The whole value of these hints is ordering: preloads are issued in
7060        // document order over one connection, so the largest chunk must go
7061        // first. The compiler sorts; the handler must not reorder or dedupe.
7062        let mut fragments = HashMap::new();
7063        fragments.insert(
7064            "index.html".to_string(),
7065            FragmentList {
7066                fragments: vec![
7067                    WebUIFragment::raw(
7068                        r#"<html><head><script type="module" async src="/index.js"></script>"#
7069                            .to_string(),
7070                    ),
7071                    structural_fragment("head_end"),
7072                    WebUIFragment::raw("</head><body>".to_string()),
7073                    structural_fragment("body_end"),
7074                    WebUIFragment::raw("</body></html>".to_string()),
7075                ],
7076            },
7077        );
7078
7079        let mut protocol = WebUIProtocol::new(fragments);
7080        protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
7081
7082        let state = test_json!({});
7083        let mut writer = TestWriter::new();
7084        handle(
7085            &protocol,
7086            &state,
7087            &RenderOptions::new("index.html", "/"),
7088            &mut writer,
7089        )
7090        .unwrap();
7091
7092        let html = writer.get_content();
7093        let head_end = html.find("</head>").expect("</head> missing");
7094        let head = &html[..head_end];
7095        assert!(
7096            head.contains(
7097                r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
7098            ),
7099            "hints must appear in <head> in the compiler's order: {html}"
7100        );
7101    }
7102
7103    #[test]
7104    fn test_no_module_preloads_emits_nothing() {
7105        let mut fragments = HashMap::new();
7106        fragments.insert(
7107            "index.html".to_string(),
7108            FragmentList {
7109                fragments: vec![
7110                    WebUIFragment::raw("<html><head>".to_string()),
7111                    structural_fragment("head_end"),
7112                    WebUIFragment::raw("</head><body>".to_string()),
7113                    structural_fragment("body_end"),
7114                    WebUIFragment::raw("</body></html>".to_string()),
7115                ],
7116            },
7117        );
7118        let protocol = WebUIProtocol::new(fragments);
7119
7120        let state = test_json!({});
7121        let mut writer = TestWriter::new();
7122        handle(
7123            &protocol,
7124            &state,
7125            &RenderOptions::new("index.html", "/"),
7126            &mut writer,
7127        )
7128        .unwrap();
7129
7130        assert!(
7131            !writer.get_content().contains("modulepreload"),
7132            "a build without hints must be byte-identical to before"
7133        );
7134    }
7135
7136    #[test]
7137    fn test_link_strategy_head_links_follow_document_order() {
7138        // Regression for #381: Link-strategy <head> CSS <link> tags must be
7139        // emitted in document/traversal order, not alphabetical tag order.
7140        // Document order here is <z-widget> then <a-widget>; an alphabetical
7141        // sort would (incorrectly) place a-widget first.
7142        let mut fragments = HashMap::new();
7143        fragments.insert(
7144            "index.html".to_string(),
7145            FragmentList {
7146                fragments: vec![
7147                    WebUIFragment::raw("<html><head>".to_string()),
7148                    structural_fragment("head_end"),
7149                    WebUIFragment::raw("</head><body><z-widget>".to_string()),
7150                    WebUIFragment::component("z-widget"),
7151                    WebUIFragment::raw("</z-widget><a-widget>".to_string()),
7152                    WebUIFragment::component("a-widget"),
7153                    WebUIFragment::raw("</a-widget>".to_string()),
7154                    structural_fragment("body_end"),
7155                    WebUIFragment::raw("</body></html>".to_string()),
7156                ],
7157            },
7158        );
7159        fragments.insert(
7160            "z-widget".to_string(),
7161            FragmentList {
7162                fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
7163            },
7164        );
7165        fragments.insert(
7166            "a-widget".to_string(),
7167            FragmentList {
7168                fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
7169            },
7170        );
7171
7172        let mut protocol = WebUIProtocol::new(fragments);
7173        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
7174        protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
7175
7176        let z = protocol
7177            .components
7178            .entry("z-widget".to_string())
7179            .or_default();
7180        z.css_href = "z-widget.css".to_string();
7181        z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
7182
7183        let a = protocol
7184            .components
7185            .entry("a-widget".to_string())
7186            .or_default();
7187        a.css_href = "a-widget.css".to_string();
7188        a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
7189
7190        let state = test_json!({});
7191        let mut writer = TestWriter::new();
7192
7193        handle(
7194            &protocol,
7195            &state,
7196            &RenderOptions::new("index.html", "/"),
7197            &mut writer,
7198        )
7199        .unwrap();
7200
7201        let html = writer.get_content();
7202        let head_end = html.find("</head>").expect("</head> missing");
7203        let head_section = &html[..head_end];
7204
7205        let z_pos = head_section
7206            .find(r#"<link rel="stylesheet" href="z-widget.css">"#)
7207            .expect("z-widget stylesheet link missing from <head>");
7208        let a_pos = head_section
7209            .find(r#"<link rel="stylesheet" href="a-widget.css">"#)
7210            .expect("a-widget stylesheet link missing from <head>");
7211
7212        assert!(
7213            z_pos < a_pos,
7214            "CSS <link> tags must follow document order (z-widget before \
7215             a-widget), not alphabetical order: {html}"
7216        );
7217    }
7218
7219    #[test]
7220    fn test_css_module_emitted_in_component_light_dom() {
7221        // CSS module <style> tags are emitted inline in the component's light DOM,
7222        // not in <head>. This keeps SSR output lean — only rendered components
7223        // get their style definitions.
7224        let mut fragments = HashMap::new();
7225        fragments.insert(
7226            "index.html".to_string(),
7227            FragmentList {
7228                fragments: vec![
7229                    WebUIFragment::raw("<html><head>".to_string()),
7230                    structural_fragment("head_end"),
7231                    WebUIFragment::raw("</head><body><my-card>".to_string()),
7232                    WebUIFragment::component("my-card"),
7233                    WebUIFragment::raw("</my-card>".to_string()),
7234                    WebUIFragment::raw("</body></html>".to_string()),
7235                ],
7236            },
7237        );
7238        fragments.insert(
7239            "my-card".to_string(),
7240            FragmentList {
7241                fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
7242            },
7243        );
7244
7245        let mut protocol = WebUIProtocol::new(fragments);
7246        protocol
7247            .components
7248            .entry("my-card".to_string())
7249            .or_default()
7250            .css = "p{color:red}".to_string();
7251        let state = test_json!({});
7252        let mut writer = TestWriter::new();
7253
7254        handle(
7255            &protocol,
7256            &state,
7257            &RenderOptions::new("index.html", "/"),
7258            &mut writer,
7259        )
7260        .unwrap();
7261
7262        let html = writer.get_content();
7263
7264        // CSS module importmap must be INSIDE the component tag (light DOM)
7265        let tag_open = html.find("<my-card>").expect("<my-card> missing");
7266        let css_pos = html
7267            .find(r#"<script type="importmap""#)
7268            .expect("CSS module importmap missing");
7269        let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
7270        assert!(
7271            css_pos > tag_open && css_pos < tag_close,
7272            "CSS module should be inside component light DOM: {html}"
7273        );
7274
7275        // <head> should NOT contain module styles
7276        let head_end = html.find("</head>").expect("</head> missing");
7277        assert!(
7278            css_pos > head_end,
7279            "CSS module should not be in <head>: {html}"
7280        );
7281    }
7282
7283    #[test]
7284    fn test_css_module_emitted_for_route_components() {
7285        // Route components get CSS modules emitted inline in their light DOM.
7286        let template = r#"<h1>Dashboard</h1>"#;
7287
7288        let mut fragments = HashMap::new();
7289        fragments.insert(
7290            "index.html".to_string(),
7291            FragmentList {
7292                fragments: vec![
7293                    WebUIFragment::raw("<html><head>".to_string()),
7294                    structural_fragment("head_end"),
7295                    WebUIFragment::raw("</head><body>".to_string()),
7296                    WebUIFragment::route("/", "dash-page"),
7297                    structural_fragment("body_end"),
7298                    WebUIFragment::raw("</body></html>".to_string()),
7299                ],
7300            },
7301        );
7302        fragments.insert(
7303            "dash-page".to_string(),
7304            FragmentList {
7305                fragments: vec![WebUIFragment::raw(template.to_string())],
7306            },
7307        );
7308
7309        let mut protocol = WebUIProtocol::new(fragments);
7310        let comp = protocol
7311            .components
7312            .entry("dash-page".to_string())
7313            .or_default();
7314        comp.css = "h1{font-size:2rem}".to_string();
7315        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
7316        let state = test_json!({});
7317        let mut writer = TestWriter::new();
7318
7319        handle(
7320            &protocol,
7321            &state,
7322            &RenderOptions::new("index.html", "/"),
7323            &mut writer,
7324        )
7325        .unwrap();
7326
7327        let html = writer.get_content();
7328
7329        assert!(
7330            html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
7331            "Route component should have CSS module importmap with data: URI: {html}"
7332        );
7333        assert!(
7334            html.contains("<h1>Dashboard</h1>"),
7335            "Route component should render content: {html}"
7336        );
7337    }
7338
7339    #[test]
7340    fn test_head_css_link_skipped_for_components_without_css() {
7341        // Regression: components without CSS files must not get <link> tags
7342        // in <head>, otherwise the browser requests a 404.
7343        let mut fragments = HashMap::new();
7344        fragments.insert(
7345            "index.html".to_string(),
7346            FragmentList {
7347                fragments: vec![
7348                    WebUIFragment::raw("<html><head>".to_string()),
7349                    structural_fragment("head_end"),
7350                    WebUIFragment::raw("</head><body>".to_string()),
7351                    WebUIFragment::component("has-css"),
7352                    WebUIFragment::component("no-css"),
7353                    structural_fragment("body_end"),
7354                    WebUIFragment::raw("</body></html>".to_string()),
7355                ],
7356            },
7357        );
7358        fragments.insert(
7359            "has-css".to_string(),
7360            FragmentList {
7361                fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
7362            },
7363        );
7364        fragments.insert(
7365            "no-css".to_string(),
7366            FragmentList {
7367                fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
7368            },
7369        );
7370
7371        let mut protocol = WebUIProtocol::new(fragments);
7372        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
7373        protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
7374
7375        // Only has-css has an external stylesheet (Link strategy)
7376        protocol
7377            .components
7378            .entry("has-css".to_string())
7379            .or_default()
7380            .css_href = "has-css.css".to_string();
7381
7382        let state = test_json!({});
7383        let mut writer = TestWriter::new();
7384
7385        let handler = WebUIHandler::with_plugin(|| {
7386            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7387        });
7388        handler
7389            .handle(
7390                &protocol,
7391                &state,
7392                &RenderOptions::new("index.html", "/"),
7393                &mut writer,
7394            )
7395            .unwrap();
7396
7397        let html = writer.get_content();
7398        assert!(
7399            html.contains(r#"<link rel="stylesheet" href="has-css.css">"#),
7400            "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
7401        );
7402        assert!(
7403            !html.contains("no-css.css"),
7404            "Component without CSS must NOT get a <link> in <head>: {html}"
7405        );
7406    }
7407
7408    #[test]
7409    fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
7410        // Simulates a page where app-shell renders cart-panel, but cart-panel
7411        // contains an <if> block with product-card inside. When the condition
7412        // is false (empty cart), product-card is NOT rendered — but it IS
7413        // reachable from the fragment graph. Its template metadata and CSS module
7414        // definition must be in the output so the client can mount it when
7415        // the <if> flips true. However, its bit must NOT be set in the
7416        // inventory — the inventory tracks what was actually rendered.
7417        let mut fragments = HashMap::new();
7418        fragments.insert(
7419            "index.html".to_string(),
7420            FragmentList {
7421                fragments: vec![
7422                    WebUIFragment::raw("<html><head>".to_string()),
7423                    structural_fragment("head_end"),
7424                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
7425                    WebUIFragment::component("app-shell"),
7426                    WebUIFragment::raw("</app-shell>".to_string()),
7427                    structural_fragment("body_end"),
7428                    WebUIFragment::raw("</body></html>".to_string()),
7429                ],
7430            },
7431        );
7432        // app-shell contains a cart panel
7433        fragments.insert(
7434            "app-shell".to_string(),
7435            FragmentList {
7436                fragments: vec![
7437                    WebUIFragment::raw("<div>Shell</div>".to_string()),
7438                    WebUIFragment::component("cart-panel"),
7439                ],
7440            },
7441        );
7442        // cart-panel has an <if> block containing product-card
7443        fragments.insert(
7444            "cart-panel".to_string(),
7445            FragmentList {
7446                fragments: vec![
7447                    WebUIFragment::raw("<aside>".to_string()),
7448                    WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
7449                    WebUIFragment::raw("</aside>".to_string()),
7450                ],
7451            },
7452        );
7453        // cart-items (if block body) contains product-card
7454        fragments.insert(
7455            "cart-items".to_string(),
7456            FragmentList {
7457                fragments: vec![WebUIFragment::component("product-card")],
7458            },
7459        );
7460        fragments.insert(
7461            "product-card".to_string(),
7462            FragmentList {
7463                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
7464            },
7465        );
7466
7467        let mut protocol = WebUIProtocol::new(fragments);
7468        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7469        for name in ["app-shell", "cart-panel", "product-card"] {
7470            let comp = protocol.components.entry(name.to_string()).or_default();
7471            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
7472            comp.css = format!(".{name}{{display:block}}");
7473            if name == "cart-panel" {
7474                comp.hydration_mode = StateProjectionMode::Keys as i32;
7475                comp.hydration_keys = vec!["hasItems".to_string()];
7476            }
7477            if name == "product-card" {
7478                comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
7479            }
7480        }
7481
7482        // Render with hasItems=false — product-card should NOT be rendered
7483        let state = test_json!({ "hasItems": false });
7484        let mut writer = TestWriter::new();
7485
7486        let handler = WebUIHandler::with_plugin(|| {
7487            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7488        });
7489        handler
7490            .handle(
7491                &protocol,
7492                &state,
7493                &RenderOptions::new("index.html", "/"),
7494                &mut writer,
7495            )
7496            .unwrap();
7497
7498        let html = writer.get_content();
7499
7500        assert!(
7501            html.contains(r#"<script type="application/json" id="webui-data">"#),
7502            "non-executable SSR metadata should be emitted in the webui-data block: {html}"
7503        );
7504        assert!(
7505            html.contains(r#""state":{"hasItems":false}"#),
7506            "SSR state should live in the JSON data block: {html}"
7507        );
7508        assert!(
7509            html.contains(r#""inventory":"#),
7510            "SSR inventory should live in the JSON data block: {html}"
7511        );
7512        assert!(
7513            !html.contains("window.__webui={\""),
7514            "executable bootstrap must not embed the window.__webui JSON literal: {html}"
7515        );
7516        assert!(
7517            !html.contains(r#"document.getElementById("webui-data")"#),
7518            "SSR must not parse webui-data; client packages own that lazy load: {html}"
7519        );
7520        assert!(
7521            !html.contains("window.__webui=w;"),
7522            "executable bootstrap must not replace existing window.__webui registrations: {html}"
7523        );
7524        assert!(
7525            !html.contains("w.templateFns={\""),
7526            "template function emission must not replace existing templateFns registrations: {html}"
7527        );
7528        assert!(
7529            html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
7530            "template functions should merge into the flat templateFns registry: {html}"
7531        );
7532
7533        // product-card template IS in the output — it's a known component
7534        // whose template must be available for client-side <if> activation.
7535        assert!(
7536            html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
7537            "product-card template should be emitted even when unrendered: {html}"
7538        );
7539
7540        // product-card CSS module IS in the output — reachable components need
7541        // their stylesheet definitions for client-side <if> activation.
7542        assert!(
7543            html.contains(r#""product-card":"data:text/css,"#),
7544            "reachable product-card CSS module importmap should be emitted: {html}"
7545        );
7546
7547        // app-shell and cart-panel SHOULD be in the output (they were rendered)
7548        assert!(
7549            html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
7550            "rendered app-shell template should be emitted: {html}"
7551        );
7552        assert!(
7553            html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
7554            "rendered cart-panel template should be emitted: {html}"
7555        );
7556    }
7557
7558    // ── CSP nonce on CSS module importmap ───────────────────────────
7559    //
7560    // When `RenderOptions::with_nonce(...)` is set, every inline
7561    // `<script type="importmap">` definition emitted during SSR for a
7562    // component CSS module must include `nonce="VALUE"` so strict CSP
7563    // `script-src 'nonce-...'` policies allow it. The without-nonce case
7564    // is already covered by other CSS module tests (e.g.
7565    // `test_css_module_emitted_for_route_components`).
7566
7567    #[test]
7568    fn test_css_module_emits_nonce_attribute_when_nonce_set() {
7569        // Per-component first-render path (`emit_css_module`).
7570        let template = r#"<h1>Dashboard</h1>"#;
7571
7572        let mut fragments = HashMap::new();
7573        fragments.insert(
7574            "index.html".to_string(),
7575            FragmentList {
7576                fragments: vec![
7577                    WebUIFragment::raw("<html><head>".to_string()),
7578                    structural_fragment("head_end"),
7579                    WebUIFragment::raw("</head><body>".to_string()),
7580                    WebUIFragment::route("/", "dash-page"),
7581                    structural_fragment("body_end"),
7582                    WebUIFragment::raw("</body></html>".to_string()),
7583                ],
7584            },
7585        );
7586        fragments.insert(
7587            "dash-page".to_string(),
7588            FragmentList {
7589                fragments: vec![WebUIFragment::raw(template.to_string())],
7590            },
7591        );
7592
7593        let mut protocol = WebUIProtocol::new(fragments);
7594        let comp = protocol
7595            .components
7596            .entry("dash-page".to_string())
7597            .or_default();
7598        comp.css = "h1{font-size:2rem}".to_string();
7599        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
7600        let state = test_json!({});
7601        let mut writer = TestWriter::new();
7602
7603        handle(
7604            &protocol,
7605            &state,
7606            &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
7607            &mut writer,
7608        )
7609        .unwrap();
7610
7611        let html = writer.get_content();
7612
7613        assert!(
7614            html.contains(
7615                r#"<script type="importmap" nonce="test-nonce-123">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
7616            ),
7617            "CSS module importmap tag should include nonce attribute in canonical order: {html}"
7618        );
7619    }
7620
7621    #[test]
7622    fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
7623        // Body-end emission path for reachable-but-unrendered components
7624        // (the second site touched by the patch). Triggered via a false
7625        // `<if>` block under hydration; requires the WebUI plugin so the
7626        // body_end hook executes.
7627        let mut fragments = HashMap::new();
7628        fragments.insert(
7629            "index.html".to_string(),
7630            FragmentList {
7631                fragments: vec![
7632                    WebUIFragment::raw("<html><head>".to_string()),
7633                    structural_fragment("head_end"),
7634                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
7635                    WebUIFragment::component("app-shell"),
7636                    WebUIFragment::raw("</app-shell>".to_string()),
7637                    structural_fragment("body_end"),
7638                    WebUIFragment::raw("</body></html>".to_string()),
7639                ],
7640            },
7641        );
7642        fragments.insert(
7643            "app-shell".to_string(),
7644            FragmentList {
7645                fragments: vec![WebUIFragment::if_cond(
7646                    ConditionExpr::identifier("hasItems"),
7647                    "cart-items",
7648                )],
7649            },
7650        );
7651        fragments.insert(
7652            "cart-items".to_string(),
7653            FragmentList {
7654                fragments: vec![WebUIFragment::component("product-card")],
7655            },
7656        );
7657        fragments.insert(
7658            "product-card".to_string(),
7659            FragmentList {
7660                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
7661            },
7662        );
7663
7664        let mut protocol = WebUIProtocol::new(fragments);
7665        for name in ["app-shell", "product-card"] {
7666            let comp = protocol.components.entry(name.to_string()).or_default();
7667            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
7668            comp.css = format!(".{name}{{display:block}}");
7669        }
7670
7671        // Render with hasItems=false so product-card is reachable but not
7672        // rendered, forcing its CSS module emission through the body_end path.
7673        let state = test_json!({ "hasItems": false });
7674        let mut writer = TestWriter::new();
7675
7676        let handler = WebUIHandler::with_plugin(|| {
7677            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7678        });
7679        handler
7680            .handle(
7681                &protocol,
7682                &state,
7683                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
7684                &mut writer,
7685            )
7686            .unwrap();
7687
7688        let html = writer.get_content();
7689
7690        assert!(
7691            html.contains(
7692                r#"<script type="importmap" nonce="test-nonce-123">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
7693            ),
7694            "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
7695        );
7696    }
7697
7698    #[test]
7699    fn projected_state_excludes_non_hydration_keys() -> Result<()> {
7700        let mut fragments = HashMap::new();
7701        fragments.insert(
7702            "index.html".to_string(),
7703            FragmentList {
7704                fragments: vec![
7705                    WebUIFragment::raw("<html><body><style>".to_string()),
7706                    WebUIFragment::signal("tokens.light", true),
7707                    WebUIFragment::raw("</style>".to_string()),
7708                    structural_fragment("body_end"),
7709                    WebUIFragment::raw("</body></html>".to_string()),
7710                ],
7711            },
7712        );
7713        fragments.insert(
7714            "app-shell".to_string(),
7715            FragmentList {
7716                fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
7717            },
7718        );
7719        let index_fragments = fragments
7720            .get_mut("index.html")
7721            .expect("index fixture should exist");
7722        index_fragments
7723            .fragments
7724            .insert(1, WebUIFragment::component("app-shell"));
7725        let mut protocol = WebUIProtocol::new(fragments);
7726        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7727        // Only `name` is a hydration key. `tokens` is a server-only field
7728        // (used above to resolve SSR CSS variables) and is NOT in the component
7729        // hydration keys,
7730        // so projection MUST keep it out of the client state block.
7731        protocol.components.insert(
7732            "app-shell".to_string(),
7733            webui_protocol::ComponentData {
7734                hydration_mode: StateProjectionMode::Keys as i32,
7735                hydration_keys: vec!["name".to_string()],
7736                ..Default::default()
7737            },
7738        );
7739        let state = test_json!({
7740            "name": "Alice",
7741            "tokens": {
7742                "light": "--color-brand: red;"
7743            }
7744        });
7745        let mut writer = TestWriter::new();
7746        let handler = WebUIHandler::with_plugin(|| {
7747            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7748        });
7749        handler.handle(
7750            &protocol,
7751            &state,
7752            &RenderOptions::new("index.html", "/"),
7753            &mut writer,
7754        )?;
7755        let output = writer.get_content();
7756
7757        // SSR still reads `tokens` to resolve the inline <style>...
7758        assert!(output.contains("--color-brand: red;"));
7759        // ...but only the hydration key reaches the client state.
7760        assert!(output.contains(r#""name":"Alice""#));
7761        assert!(!output.contains(r#""tokens""#));
7762        Ok(())
7763    }
7764
7765    #[test]
7766    fn full_initial_strategy_preserves_complete_state() -> Result<()> {
7767        let mut fragments = HashMap::new();
7768        fragments.insert(
7769            "index.html".to_string(),
7770            FragmentList {
7771                fragments: vec![
7772                    WebUIFragment::raw("<html><body>"),
7773                    structural_fragment("body_end"),
7774                    WebUIFragment::raw("</body></html>"),
7775                ],
7776            },
7777        );
7778        let protocol = WebUIProtocol::new(fragments);
7779        let state = test_json!({
7780            "client": "visible",
7781            "serverOnly": "also preserved",
7782        });
7783        let handler = WebUIHandler::with_plugin(|| {
7784            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7785        });
7786        let mut writer = TestWriter::new();
7787        handler.handle(
7788            &protocol,
7789            &state,
7790            &RenderOptions::new("index.html", "/"),
7791            &mut writer,
7792        )?;
7793        let output = writer.get_content();
7794        assert!(output.contains(r#""client":"visible""#));
7795        assert!(output.contains(r#""serverOnly":"also preserved""#));
7796        Ok(())
7797    }
7798
7799    #[test]
7800    fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
7801        let mut fragments = HashMap::new();
7802        fragments.insert(
7803            "index.html".to_string(),
7804            FragmentList {
7805                fragments: vec![
7806                    WebUIFragment::raw("<html><body>"),
7807                    WebUIFragment::component("app-shell"),
7808                    structural_fragment("body_end"),
7809                    WebUIFragment::raw("</body></html>"),
7810                ],
7811            },
7812        );
7813        fragments.insert(
7814            "app-shell".to_string(),
7815            FragmentList {
7816                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
7817            },
7818        );
7819        let mut protocol = WebUIProtocol::new(fragments);
7820        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7821        protocol.components.insert(
7822            "app-shell".to_string(),
7823            webui_protocol::ComponentData {
7824                hydration_mode: StateProjectionMode::All as i32,
7825                ..Default::default()
7826            },
7827        );
7828        let state = test_json!({
7829            "known": "value",
7830            "possiblyInherited": "must not be dropped",
7831        });
7832        let handler = WebUIHandler::with_plugin(|| {
7833            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7834        });
7835        let mut writer = TestWriter::new();
7836        handler.handle(
7837            &protocol,
7838            &state,
7839            &RenderOptions::new("index.html", "/"),
7840            &mut writer,
7841        )?;
7842        let output = writer.get_content();
7843        assert!(output.contains(r#""known":"value""#));
7844        assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
7845        Ok(())
7846    }
7847
7848    #[test]
7849    fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
7850        let mut fragments = HashMap::new();
7851        fragments.insert(
7852            "index.html".to_string(),
7853            FragmentList {
7854                fragments: vec![
7855                    WebUIFragment::raw("<html><body>"),
7856                    WebUIFragment::component("app-shell"),
7857                    structural_fragment("body_end"),
7858                    WebUIFragment::raw("</body></html>"),
7859                ],
7860            },
7861        );
7862        fragments.insert(
7863            "app-shell".to_string(),
7864            FragmentList {
7865                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
7866            },
7867        );
7868        let mut protocol = WebUIProtocol::new(fragments);
7869        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7870        let state = test_json!({
7871            "known": "value",
7872            "serverOnly": "must not be dropped",
7873        });
7874        let handler = WebUIHandler::with_plugin(|| {
7875            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7876        });
7877        let mut writer = TestWriter::new();
7878        handler.handle(
7879            &protocol,
7880            &state,
7881            &RenderOptions::new("index.html", "/"),
7882            &mut writer,
7883        )?;
7884        let output = writer.get_content();
7885        assert!(output.contains(r#""known":"value""#));
7886        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
7887        Ok(())
7888    }
7889
7890    #[test]
7891    fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
7892        let mut fragments = HashMap::new();
7893        fragments.insert(
7894            "index.html".to_string(),
7895            FragmentList {
7896                fragments: vec![
7897                    WebUIFragment::raw("<html><body>"),
7898                    WebUIFragment::component("app-shell"),
7899                    structural_fragment("body_end"),
7900                    WebUIFragment::raw("</body></html>"),
7901                ],
7902            },
7903        );
7904        fragments.insert(
7905            "app-shell".to_string(),
7906            FragmentList {
7907                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
7908            },
7909        );
7910        let mut protocol = WebUIProtocol::new(fragments);
7911        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7912        protocol.components.insert(
7913            "app-shell".to_string(),
7914            webui_protocol::ComponentData {
7915                hydration_mode: i32::MAX,
7916                ..Default::default()
7917            },
7918        );
7919        let state = test_json!({
7920            "known": "value",
7921            "serverOnly": "must not be dropped",
7922        });
7923        let handler = WebUIHandler::with_plugin(|| {
7924            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7925        });
7926        let mut writer = TestWriter::new();
7927        handler.handle(
7928            &protocol,
7929            &state,
7930            &RenderOptions::new("index.html", "/"),
7931            &mut writer,
7932        )?;
7933        let output = writer.get_content();
7934        assert!(output.contains(r#""known":"value""#));
7935        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
7936        Ok(())
7937    }
7938
7939    #[test]
7940    fn legacy_navigation_keys_with_default_mode_remain_keyed() {
7941        let mut protocol = WebUIProtocol::new(HashMap::new());
7942        protocol.components.insert(
7943            "app-shell".to_string(),
7944            webui_protocol::ComponentData {
7945                navigation_keys: vec!["selected".to_string()],
7946                ..Default::default()
7947            },
7948        );
7949
7950        match collect_navigation_state(&protocol, ["app-shell"]) {
7951            StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
7952            StateSelection::Full | StateSelection::BorrowedKeys(_) => {
7953                panic!("legacy navigation keys should remain owned and projected")
7954            }
7955        }
7956    }
7957
7958    #[test]
7959    fn legacy_navigation_without_projection_metadata_preserves_full_state() {
7960        let mut protocol = WebUIProtocol::new(HashMap::new());
7961        protocol.components.insert(
7962            "app-shell".to_string(),
7963            webui_protocol::ComponentData::default(),
7964        );
7965
7966        assert!(matches!(
7967            collect_navigation_state(&protocol, ["app-shell"]),
7968            StateSelection::Full
7969        ));
7970    }
7971
7972    #[test]
7973    fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
7974        let mut fragments = HashMap::new();
7975        fragments.insert(
7976            "index.html".to_string(),
7977            FragmentList {
7978                fragments: vec![
7979                    WebUIFragment::raw("<html><body>".to_string()),
7980                    structural_fragment("body_end"),
7981                    WebUIFragment::raw("</body></html>".to_string()),
7982                ],
7983            },
7984        );
7985        let mut protocol = WebUIProtocol::new(fragments);
7986        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7987        let state = test_json!({
7988            "title": "Legacy state",
7989            "serverOnly": "preserved",
7990        });
7991        let handler = WebUIHandler::with_plugin(|| {
7992            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7993        });
7994
7995        let mut writer = TestWriter::new();
7996        handler.handle(
7997            &protocol,
7998            &state,
7999            &RenderOptions::new("index.html", "/"),
8000            &mut writer,
8001        )?;
8002        assert!(writer.get_content().contains(r#""state":{}"#));
8003        assert!(!writer.get_content().contains("Legacy state"));
8004        assert!(!writer.get_content().contains("preserved"));
8005        Ok(())
8006    }
8007
8008    #[test]
8009    fn scriptless_component_state_is_navigation_only() -> Result<()> {
8010        let mut fragments = HashMap::new();
8011        fragments.insert(
8012            "index.html".to_string(),
8013            FragmentList {
8014                fragments: vec![
8015                    WebUIFragment::raw("<html><body>"),
8016                    WebUIFragment::component("items-page"),
8017                    structural_fragment("body_end"),
8018                    WebUIFragment::raw("</body></html>"),
8019                ],
8020            },
8021        );
8022        fragments.insert(
8023            "items-page".to_string(),
8024            FragmentList {
8025                fragments: vec![WebUIFragment::raw("<p>Items</p>")],
8026            },
8027        );
8028        let mut protocol = WebUIProtocol::new(fragments);
8029        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
8030        protocol.components.insert(
8031            "items-page".to_string(),
8032            webui_protocol::ComponentData {
8033                template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
8034                navigation_mode: Some(StateProjectionMode::Keys as i32),
8035                navigation_keys: vec!["items".into()],
8036                ..Default::default()
8037            },
8038        );
8039        let state = test_json!({
8040            "items": ["STATE_SENTINEL"],
8041            "serverOnly": "SECRET_SENTINEL",
8042        });
8043        let handler = WebUIHandler::with_plugin(|| {
8044            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8045        });
8046        let mut writer = TestWriter::new();
8047
8048        handler.handle(
8049            &protocol,
8050            &state,
8051            &RenderOptions::new("index.html", "/"),
8052            &mut writer,
8053        )?;
8054
8055        let output = writer.get_content();
8056        assert!(output.contains(r#""state":{}"#));
8057        assert!(!output.contains("STATE_SENTINEL"));
8058        assert!(!output.contains("SECRET_SENTINEL"));
8059        Ok(())
8060    }
8061
8062    #[test]
8063    fn write_selected_state_projects_and_escapes() {
8064        // `keep` is in the sorted key set and its value contains a `</` that
8065        // must be escaped; `drop` is absent and must be projected out.
8066        let state = test_json!({
8067            "drop": "secret",
8068            "keep": "</script><b>"
8069        });
8070        let keys = ["keep"];
8071        let selection = StateSelection::Keys(keys.to_vec());
8072        let mut sink = TestWriter::new();
8073        let mut scratch = Vec::new();
8074        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
8075        assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
8076    }
8077
8078    #[test]
8079    fn write_selected_state_non_object_projection_emits_empty_object() {
8080        let state = test_json!("scalar state has nothing hydratable");
8081        let keys: [&str; 0] = [];
8082        let selection = StateSelection::Keys(keys.to_vec());
8083        let mut sink = TestWriter::new();
8084        let mut scratch = Vec::new();
8085        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
8086        assert_eq!(sink.get_content(), "{}");
8087    }
8088
8089    #[test]
8090    fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
8091        let state = test_json!({
8092            "keptA": 1,
8093            "keptB": 2,
8094            "serverOnlyA": 3,
8095            "serverOnlyB": 4,
8096        });
8097        let keys = ["keptA", "keptA", "keptB", "missing"];
8098        let selection = StateSelection::Keys(keys.to_vec());
8099        let mut sink = TestWriter::new();
8100        let mut scratch = Vec::new();
8101        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
8102        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
8103    }
8104
8105    #[test]
8106    fn write_selected_state_map_first_matches_schema_first_output() {
8107        let state = test_json!({
8108            "keptA": 1,
8109            "keptB": 2,
8110        });
8111        let keys = ["keptA", "keptB", "missingA", "missingB"];
8112        let selection = StateSelection::Keys(keys.to_vec());
8113        let mut sink = TestWriter::new();
8114        let mut scratch = Vec::new();
8115        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
8116        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
8117    }
8118
8119    #[test]
8120    fn write_selected_state_full_preserves_and_escapes_state() {
8121        let state = test_json!({
8122            "serverOnly": "</script><b>",
8123            "value": 42,
8124        });
8125        let mut sink = TestWriter::new();
8126        let mut scratch = Vec::new();
8127        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
8128        assert_eq!(
8129            sink.get_content(),
8130            r#"{"serverOnly":"<\/script><b>","value":42}"#
8131        );
8132    }
8133
8134    #[test]
8135    fn json_scratch_capacity_reused_across_checkpoint_payloads() {
8136        // Simulates several checkpoint bootstraps serialized in one render: a
8137        // single request-local buffer must grow once for the largest payload and
8138        // then be reused for subsequent payloads without reallocating. This is
8139        // the deterministic seam behind the streaming path's allocation gate —
8140        // `write_script_safe_json` is `pub(crate)`, so no public API is exposed.
8141        let mut sink = TestWriter::new();
8142        let mut scratch: Vec<u8> = Vec::new();
8143        // No allocation until serialization actually needs it.
8144        assert_eq!(scratch.capacity(), 0);
8145
8146        // The largest payload first establishes the high-water capacity.
8147        let mut large = serde_json::Map::new();
8148        for index in 0..64 {
8149            large.insert(
8150                format!("component_{index:04}"),
8151                Value::String("x".repeat(48)),
8152            );
8153        }
8154        let large = Value::Object(large);
8155        write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
8156        let high_water = scratch.capacity();
8157        assert!(high_water > 0);
8158
8159        // Subsequent smaller checkpoint payloads reuse the same buffer: capacity
8160        // never grows (no per-checkpoint allocation) and never shrinks.
8161        for seq in 0..8 {
8162            let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
8163            write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
8164            assert_eq!(
8165                scratch.capacity(),
8166                high_water,
8167                "scratch reallocated on checkpoint {seq}"
8168            );
8169        }
8170    }
8171
8172    #[test]
8173    fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
8174        let mut fragments = HashMap::new();
8175        fragments.insert(
8176            "index.html".to_string(),
8177            FragmentList {
8178                fragments: vec![
8179                    WebUIFragment::raw("<html><body>"),
8180                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
8181                        path: "/".to_string(),
8182                        fragment_id: "home-page".to_string(),
8183                        exact: true,
8184                        ..Default::default()
8185                    }),
8186                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
8187                        path: "/admin".to_string(),
8188                        fragment_id: "admin-page".to_string(),
8189                        exact: true,
8190                        ..Default::default()
8191                    }),
8192                    structural_fragment("body_end"),
8193                    WebUIFragment::raw("</body></html>"),
8194                ],
8195            },
8196        );
8197        fragments.insert(
8198            "home-page".to_string(),
8199            FragmentList {
8200                fragments: vec![WebUIFragment::raw("<p>Home</p>")],
8201            },
8202        );
8203        fragments.insert(
8204            "admin-page".to_string(),
8205            FragmentList {
8206                fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
8207            },
8208        );
8209
8210        let mut protocol = WebUIProtocol::new(fragments);
8211        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
8212        protocol.components.insert(
8213            "home-page".to_string(),
8214            webui_protocol::ComponentData {
8215                template_json: "{}".to_string(),
8216                hydration_mode: StateProjectionMode::Keys as i32,
8217                hydration_keys: vec!["homeTitle".to_string()],
8218                ..Default::default()
8219            },
8220        );
8221        protocol.components.insert(
8222            "admin-page".to_string(),
8223            webui_protocol::ComponentData {
8224                template_json: "{}".to_string(),
8225                hydration_mode: StateProjectionMode::Keys as i32,
8226                hydration_keys: vec!["adminToken".to_string()],
8227                ..Default::default()
8228            },
8229        );
8230        let state = test_json!({
8231            "homeTitle": "Welcome",
8232            "adminToken": "TOP_SECRET_SENTINEL",
8233        });
8234        let handler = WebUIHandler::with_plugin(|| {
8235            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8236        });
8237        let mut writer = TestWriter::new();
8238        handler.handle(
8239            &protocol,
8240            &state,
8241            &RenderOptions::new("index.html", "/"),
8242            &mut writer,
8243        )?;
8244        let output = writer.get_content();
8245        assert!(output.contains(r#""homeTitle":"Welcome""#));
8246        assert!(!output.contains("TOP_SECRET_SENTINEL"));
8247        assert!(!output.contains(r#""adminToken""#));
8248        Ok(())
8249    }
8250
8251    #[test]
8252    fn test_component_attr_name_aria() {
8253        // component_attr_name correctly maps ARIA attributes via the shared table
8254        assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
8255        assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
8256        assert_eq!(
8257            component_attr_name("aria-activedescendant"),
8258            "ariaActiveDescendant"
8259        );
8260        assert_eq!(component_attr_name("aria-label"), "ariaLabel");
8261        assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
8262    }
8263
8264    #[test]
8265    fn test_component_attr_name_html_global() {
8266        assert_eq!(component_attr_name("readonly"), "readOnly");
8267        assert_eq!(component_attr_name("tabindex"), "tabIndex");
8268        assert_eq!(component_attr_name("accesskey"), "accessKey");
8269        assert_eq!(component_attr_name("contenteditable"), "contentEditable");
8270        assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
8271        assert_eq!(component_attr_name("inputmode"), "inputMode");
8272        assert_eq!(component_attr_name("maxlength"), "maxLength");
8273        assert_eq!(component_attr_name("minlength"), "minLength");
8274        assert_eq!(component_attr_name("novalidate"), "noValidate");
8275        assert_eq!(component_attr_name("formaction"), "formAction");
8276        assert_eq!(component_attr_name("ismap"), "isMap");
8277        assert_eq!(component_attr_name("usemap"), "useMap");
8278    }
8279
8280    #[test]
8281    fn test_component_attr_name_strips_colon() {
8282        assert_eq!(component_attr_name(":readonly"), "readOnly");
8283        assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
8284        assert_eq!(component_attr_name(":data-title"), "dataTitle");
8285    }
8286
8287    #[test]
8288    fn test_component_attr_name_regular() {
8289        assert_eq!(component_attr_name("data-title"), "dataTitle");
8290        assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
8291        assert_eq!(component_attr_name("simple"), "simple");
8292    }
8293
8294    // ── allowed_query SSR emission tests ─────────────────────────────
8295
8296    fn make_query_route_protocol() -> WebUIProtocol {
8297        use webui_protocol::WebUiFragmentRoute;
8298
8299        let mut fragments = HashMap::new();
8300
8301        fragments.insert(
8302            "index.html".to_string(),
8303            FragmentList {
8304                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
8305                    path: "/".into(),
8306                    fragment_id: "app-shell".into(),
8307                    exact: false,
8308                    children: vec![
8309                        WebUiFragmentRoute {
8310                            path: "compose".into(),
8311                            fragment_id: "compose-page".into(),
8312                            exact: true,
8313                            allowed_query: "action,to,subject".into(),
8314                            keep_alive: false,
8315                            ..Default::default()
8316                        },
8317                        WebUiFragmentRoute {
8318                            path: "settings".into(),
8319                            fragment_id: "settings-page".into(),
8320                            exact: true,
8321                            keep_alive: false,
8322                            ..Default::default()
8323                        },
8324                    ],
8325                    keep_alive: false,
8326                    ..Default::default()
8327                })],
8328            },
8329        );
8330
8331        fragments.insert(
8332            "app-shell".to_string(),
8333            FragmentList {
8334                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
8335            },
8336        );
8337        fragments.insert(
8338            "compose-page".to_string(),
8339            FragmentList {
8340                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
8341            },
8342        );
8343        fragments.insert(
8344            "settings-page".to_string(),
8345            FragmentList {
8346                fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
8347            },
8348        );
8349
8350        WebUIProtocol::new(fragments)
8351    }
8352
8353    #[test]
8354    fn test_matched_route_omits_query_attr_from_dom() {
8355        let protocol = make_query_route_protocol();
8356        let state = test_json!({});
8357        let handler = WebUIHandler::new();
8358        let mut writer = TestWriter::new();
8359
8360        handler
8361            .handle(
8362                &protocol,
8363                &state,
8364                &RenderOptions::new("index.html", "/compose"),
8365                &mut writer,
8366            )
8367            .expect("render failed");
8368
8369        let html = writer.get_content();
8370        // query attr is no longer in DOM — it's in the SSR chain JSON instead
8371        assert!(
8372            !html.contains(r#"query="action,to,subject""#),
8373            "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
8374        );
8375    }
8376
8377    #[test]
8378    fn test_nonmatched_route_omits_query_attr_from_dom() {
8379        let protocol = make_query_route_protocol();
8380        let state = test_json!({});
8381        let handler = WebUIHandler::new();
8382        let mut writer = TestWriter::new();
8383
8384        handler
8385            .handle(
8386                &protocol,
8387                &state,
8388                &RenderOptions::new("index.html", "/settings"),
8389                &mut writer,
8390            )
8391            .expect("render failed");
8392
8393        let html = writer.get_content();
8394        // query attr should not be on hidden siblings either
8395        assert!(
8396            !html.contains(r#"query="#),
8397            "hidden route should not have query attr: {html}"
8398        );
8399    }
8400
8401    #[test]
8402    fn test_route_without_query_has_no_query_attr() {
8403        let protocol = make_query_route_protocol();
8404        let state = test_json!({});
8405        let handler = WebUIHandler::new();
8406        let mut writer = TestWriter::new();
8407
8408        handler
8409            .handle(
8410                &protocol,
8411                &state,
8412                &RenderOptions::new("index.html", "/settings"),
8413                &mut writer,
8414            )
8415            .expect("render failed");
8416
8417        let html = writer.get_content();
8418        // Find the settings route element and verify it has no query attr
8419        let settings_idx = html
8420            .find(r#"component="settings-page""#)
8421            .expect("settings route should exist");
8422        let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
8423        assert!(
8424            !settings_tag.contains("query="),
8425            "route without allowed_query should not emit query attr: {settings_tag}"
8426        );
8427    }
8428
8429    // ── Per-render head_inject / body_inject (replaces the byte-scanner
8430    //    InjectingStreamingWriter approach with structural signal-based
8431    //    injection) ───────────────────────────────────────────────────
8432
8433    fn build_head_body_protocol() -> WebUIProtocol {
8434        let mut fragments = HashMap::new();
8435        fragments.insert(
8436            "index.html".to_string(),
8437            FragmentList {
8438                fragments: vec![
8439                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
8440                    structural_fragment("head_end"),
8441                    WebUIFragment::raw("</head><body>hello".to_string()),
8442                    structural_fragment("body_end"),
8443                    WebUIFragment::raw("</body></html>".to_string()),
8444                ],
8445            },
8446        );
8447        WebUIProtocol::new(fragments)
8448    }
8449
8450    #[test]
8451    fn head_inject_emits_at_head_end_boundary() {
8452        let protocol = build_head_body_protocol();
8453        let state = test_json!({});
8454        let mut writer = TestWriter::new();
8455        let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
8456        handle(&protocol, &state, &opts, &mut writer).unwrap();
8457        let html = writer.get_content();
8458        // The inject must appear immediately before `</head>`.
8459        let inject_idx = html
8460            .find("<link rel=preload>")
8461            .expect("inject HTML missing");
8462        let head_close_idx = html.find("</head>").expect("</head> missing");
8463        assert!(
8464            inject_idx < head_close_idx,
8465            "head_inject must appear before </head>: {html}"
8466        );
8467        // No duplicate.
8468        assert_eq!(html.matches("<link rel=preload>").count(), 1);
8469    }
8470
8471    #[test]
8472    fn body_inject_emits_at_body_end_boundary() {
8473        let protocol = build_head_body_protocol();
8474        let state = test_json!({});
8475        let mut writer = TestWriter::new();
8476        let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
8477        handle(&protocol, &state, &opts, &mut writer).unwrap();
8478        let html = writer.get_content();
8479        let inject_idx = html
8480            .find("<script>lr</script>")
8481            .expect("inject HTML missing");
8482        let body_close_idx = html.find("</body>").expect("</body> missing");
8483        assert!(
8484            inject_idx < body_close_idx,
8485            "body_inject must appear before </body>: {html}"
8486        );
8487        assert_eq!(html.matches("<script>lr</script>").count(), 1);
8488    }
8489
8490    #[test]
8491    fn injects_are_no_op_when_unset() {
8492        let protocol = build_head_body_protocol();
8493        let state = test_json!({});
8494        let mut writer = TestWriter::new();
8495        handle(
8496            &protocol,
8497            &state,
8498            &RenderOptions::new("index.html", "/"),
8499            &mut writer,
8500        )
8501        .unwrap();
8502        let html = writer.get_content();
8503        assert!(!html.contains("<link rel=preload>"));
8504        assert!(!html.contains("<script>lr</script>"));
8505    }
8506
8507    #[test]
8508    fn empty_inject_string_treated_as_unset() {
8509        let protocol = build_head_body_protocol();
8510        let state = test_json!({});
8511        let mut writer = TestWriter::new();
8512        let opts = RenderOptions::new("index.html", "/")
8513            .with_head_inject("")
8514            .with_body_inject("");
8515        handle(&protocol, &state, &opts, &mut writer).unwrap();
8516        // No injection happens — empty strings are normalised to None
8517        // by the builder, so the output is identical to the no-options case.
8518        let html = writer.get_content();
8519        assert!(html.contains("</head>"));
8520        assert!(html.contains("</body>"));
8521    }
8522
8523    #[test]
8524    fn inject_html_is_passed_through_verbatim() {
8525        // The handler does NOT escape the inject string — hosts pass
8526        // raw HTML they trust. This test pins that contract: a `<` in
8527        // the inject is emitted as-is, not encoded as `&lt;`.
8528        let protocol = build_head_body_protocol();
8529        let state = test_json!({});
8530        let mut writer = TestWriter::new();
8531        let opts =
8532            RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
8533        handle(&protocol, &state, &opts, &mut writer).unwrap();
8534        assert!(writer.get_content().contains("<script>var x=1;</script>"));
8535    }
8536
8537    /// Both injects fire and appear at the correct structural
8538    /// positions. Critically, this is robust against `</head>` /
8539    /// `</body>` literals appearing elsewhere in the document — the
8540    /// signal-based emitter cannot mis-fire on byte patterns inside
8541    /// HTML comments, `<iframe srcdoc>`, or inline scripts (which the
8542    /// previous byte-scanner could).
8543    #[test]
8544    fn injects_robust_against_marker_literals_in_content() {
8545        let mut fragments = HashMap::new();
8546        // The body intentionally contains `</body>` and `</head>`
8547        // literals before the actual structural close — these came
8548        // from a (hypothetical) iframe srcdoc or comment.
8549        fragments.insert(
8550            "index.html".to_string(),
8551            FragmentList {
8552                fragments: vec![
8553                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
8554                    structural_fragment("head_end"),
8555                    WebUIFragment::raw(
8556                        "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
8557                    ),
8558                    structural_fragment("body_end"),
8559                    WebUIFragment::raw("</body></html>".to_string()),
8560                ],
8561            },
8562        );
8563        let protocol = WebUIProtocol::new(fragments);
8564        let state = test_json!({});
8565        let mut writer = TestWriter::new();
8566        let opts = RenderOptions::new("index.html", "/")
8567            .with_head_inject("<HEAD-INJ>")
8568            .with_body_inject("<BODY-INJ>");
8569        handle(&protocol, &state, &opts, &mut writer).unwrap();
8570        let html = writer.get_content();
8571        // The head inject sits between `<title>x</title>` and the
8572        // first `</head>` — the structural one, not the comment one.
8573        let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
8574        let head_close_idx = html.find("</head>").expect("</head> missing");
8575        assert!(head_inj_idx < head_close_idx);
8576        // The body inject sits before the structural `</body>` — NOT
8577        // before the `</body>` literal in the comment (which would
8578        // require the inject to appear inside `<p>hi</p>` somewhere).
8579        let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
8580        // Find the LAST `</body>` (the structural one).
8581        let body_close_idx = html.rfind("</body>").expect("</body> missing");
8582        assert!(
8583            body_inj_idx < body_close_idx,
8584            "body_inject must precede the structural </body>: {html}"
8585        );
8586        // And the comment is preserved verbatim.
8587        assert!(html.contains("<!-- </body> </head> -->"));
8588    }
8589
8590    /// Coverage-14: both `head_inject` AND `body_inject` set in the
8591    /// same render. Each fires at the correct structural boundary and
8592    /// neither leaks into the other's region.
8593    #[test]
8594    fn both_injects_fire_at_correct_boundaries() {
8595        let protocol = build_head_body_protocol();
8596        let state = test_json!({});
8597        let mut writer = TestWriter::new();
8598        let opts = RenderOptions::new("index.html", "/")
8599            .with_head_inject("<META-HEAD>")
8600            .with_body_inject("<SCRIPT-BODY>");
8601        handle(&protocol, &state, &opts, &mut writer).unwrap();
8602        let html = writer.get_content();
8603        let head_idx = html.find("<META-HEAD>").expect("head inject missing");
8604        let head_close = html.find("</head>").expect("</head> missing");
8605        let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
8606        let body_close = html.find("</body>").expect("</body> missing");
8607        assert!(head_idx < head_close, "head_inject before </head>");
8608        assert!(head_close < body_idx, "body_inject after </head>");
8609        assert!(body_idx < body_close, "body_inject before </body>");
8610        assert_eq!(html.matches("<META-HEAD>").count(), 1);
8611        assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
8612    }
8613
8614    /// Coverage-15 / Bug-3 (security defense): a malformed protocol
8615    /// emitting `head_end` and `body_end` more than once must NOT
8616    /// duplicate the host inject HTML. Without the dedup guard,
8617    /// double-emission would amplify Security-2 (a 1 MiB inject ×
8618    /// 1000 duplicate signals = 1 GiB output).
8619    #[test]
8620    fn injects_dedupe_against_duplicate_signals() {
8621        let mut fragments = HashMap::new();
8622        fragments.insert(
8623            "index.html".to_string(),
8624            FragmentList {
8625                fragments: vec![
8626                    WebUIFragment::raw("<html><head>".to_string()),
8627                    structural_fragment("head_end"),
8628                    structural_fragment("head_end"), // duplicate
8629                    structural_fragment("head_end"), // triplicate
8630                    WebUIFragment::raw("</head><body>".to_string()),
8631                    structural_fragment("body_end"),
8632                    structural_fragment("body_end"), // duplicate
8633                    WebUIFragment::raw("</body></html>".to_string()),
8634                ],
8635            },
8636        );
8637        let protocol = WebUIProtocol::new(fragments);
8638        let state = test_json!({});
8639        let mut writer = TestWriter::new();
8640        let opts = RenderOptions::new("index.html", "/")
8641            .with_head_inject("<HINJ>")
8642            .with_body_inject("<BINJ>");
8643        handle(&protocol, &state, &opts, &mut writer).unwrap();
8644        let html = writer.get_content();
8645        assert_eq!(
8646            html.matches("<HINJ>").count(),
8647            1,
8648            "head_inject must emit exactly once even with duplicate head_end signals"
8649        );
8650        assert_eq!(
8651            html.matches("<BINJ>").count(),
8652            1,
8653            "body_inject must emit exactly once even with duplicate body_end signals"
8654        );
8655    }
8656
8657    /// Coverage-15: a Shadow-DOM / component-only protocol that has NO
8658    /// `<head>` / `<body>` tags must NOT emit the inject (the signals
8659    /// never fire). Verifies the injects are no-ops, not panics.
8660    #[test]
8661    fn injects_no_op_when_no_head_or_body_signals() {
8662        let mut fragments = HashMap::new();
8663        fragments.insert(
8664            "index.html".to_string(),
8665            FragmentList {
8666                fragments: vec![WebUIFragment::raw(
8667                    "<my-component>hi</my-component>".to_string(),
8668                )],
8669            },
8670        );
8671        let protocol = WebUIProtocol::new(fragments);
8672        let state = test_json!({});
8673        let mut writer = TestWriter::new();
8674        let opts = RenderOptions::new("index.html", "/")
8675            .with_head_inject("<HINJ>")
8676            .with_body_inject("<BINJ>");
8677        handle(&protocol, &state, &opts, &mut writer).unwrap();
8678        let html = writer.get_content();
8679        assert!(!html.contains("<HINJ>"), "head_inject must not appear");
8680        assert!(!html.contains("<BINJ>"), "body_inject must not appear");
8681        assert!(html.contains("<my-component>"));
8682    }
8683
8684    /// Coverage-19: the handler's `&self` is shared across threads.
8685    /// Two concurrent renders with different inject values must NOT
8686    /// cross-contaminate (each thread sees only its own inject).
8687    /// Per-render mutable state lives on the `WebUIProcessContext`,
8688    /// which is stack-allocated per call.
8689    #[test]
8690    fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
8691        let protocol = std::sync::Arc::new(build_head_body_protocol());
8692        let state = std::sync::Arc::new(test_json!({}));
8693        let handler = std::sync::Arc::new(WebUIHandler::new());
8694
8695        const N_THREADS: usize = 16;
8696        let mut handles = Vec::with_capacity(N_THREADS);
8697        for tid in 0..N_THREADS {
8698            let h = std::sync::Arc::clone(&handler);
8699            let p = std::sync::Arc::clone(&protocol);
8700            let s = std::sync::Arc::clone(&state);
8701            handles.push(std::thread::spawn(move || {
8702                let head = format!("<HEAD-T{tid}>");
8703                let body = format!("<BODY-T{tid}>");
8704                let mut writer = TestWriter::new();
8705                let opts = RenderOptions::new("index.html", "/")
8706                    .with_head_inject(&head)
8707                    .with_body_inject(&body);
8708                h.handle(&p, &s, &opts, &mut writer).unwrap();
8709                let html = writer.get_content();
8710                // Must contain my own injects exactly once.
8711                assert_eq!(html.matches(&head).count(), 1);
8712                assert_eq!(html.matches(&body).count(), 1);
8713                // Must NOT contain any other thread's inject.
8714                for other in 0..N_THREADS {
8715                    if other == tid {
8716                        continue;
8717                    }
8718                    let other_head = format!("<HEAD-T{other}>");
8719                    let other_body = format!("<BODY-T{other}>");
8720                    assert!(
8721                        !html.contains(&other_head),
8722                        "tid {tid} saw {other}'s head_inject"
8723                    );
8724                    assert!(
8725                        !html.contains(&other_body),
8726                        "tid {tid} saw {other}'s body_inject"
8727                    );
8728                }
8729            }));
8730        }
8731        for h in handles {
8732            h.join().expect("worker panicked");
8733        }
8734    }
8735
8736    /// Coverage-17: a large (1 MiB) head_inject must round-trip
8737    /// correctly without panic, truncation, or excessive overhead.
8738    /// (No size cap is enforced by the handler — the host owns the
8739    /// safety contract; see `with_head_inject` doc comment.)
8740    #[test]
8741    fn large_inject_roundtrips_without_truncation() {
8742        let protocol = build_head_body_protocol();
8743        let state = test_json!({});
8744        let mut writer = TestWriter::new();
8745        let big = "x".repeat(1024 * 1024);
8746        let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
8747        handle(&protocol, &state, &opts, &mut writer).unwrap();
8748        let html = writer.get_content();
8749        assert!(
8750            html.contains(&big),
8751            "large head_inject must be present verbatim ({} bytes)",
8752            big.len()
8753        );
8754        // Sanity: only one copy.
8755        assert_eq!(html.matches(&big).count(), 1);
8756    }
8757
8758    /// `with_nonce("")` must normalize to `None` (no `<meta>` emitted),
8759    /// matching the empty-string semantics of `with_head_inject` /
8760    /// `with_body_inject`. An empty content attribute is browser-
8761    /// ignored noise.
8762    #[test]
8763    fn empty_nonce_treated_as_unset() {
8764        let protocol = build_head_body_protocol();
8765        let state = test_json!({});
8766        let mut writer = TestWriter::new();
8767        let opts = RenderOptions::new("index.html", "/").with_nonce("");
8768        handle(&protocol, &state, &opts, &mut writer).unwrap();
8769        assert!(
8770            !writer.get_content().contains("webui-nonce"),
8771            "empty nonce must not emit <meta name=\"webui-nonce\">"
8772        );
8773    }
8774
8775    /// Regression for the bug Akrosh caught: the `pub` fields on
8776    /// `RenderOptions` let a caller bypass the `with_*` builder
8777    /// normalisation, e.g.:
8778    ///
8779    /// ```ignore
8780    /// RenderOptions { nonce: Some(""), ..RenderOptions::new(e, p) }
8781    /// ```
8782    ///
8783    /// Without defensive normalisation at handler init, this would
8784    /// emit `<script nonce="">` on every inline script. Under a
8785    /// strict `Content-Security-Policy: script-src 'nonce-...'` an
8786    /// empty nonce is a HARD CSP failure that blocks every inline
8787    /// script — a complete inline-script-execution outage.
8788    ///
8789    /// The handler now treats `Some("")` identically to `None` for
8790    /// all three injection points (nonce / head_inject / body_inject)
8791    /// regardless of how the option was populated.
8792    #[test]
8793    fn empty_field_bypass_is_normalised_at_handler_init() {
8794        let protocol = build_head_body_protocol();
8795        let state = test_json!({});
8796
8797        // Bypass the `with_nonce` builder by writing the field directly.
8798        let opts_with_empty_nonce = RenderOptions {
8799            nonce: Some(""),
8800            ..RenderOptions::new("index.html", "/")
8801        };
8802        let mut writer = TestWriter::new();
8803        handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
8804        let html = writer.get_content();
8805        assert!(
8806            !html.contains("webui-nonce"),
8807            "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
8808        );
8809        assert!(
8810            !html.contains("nonce=\"\""),
8811            "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
8812        );
8813
8814        // Same defence for inject fields.
8815        let opts_with_empty_injects = RenderOptions {
8816            head_inject: Some(""),
8817            body_inject: Some(""),
8818            ..RenderOptions::new("index.html", "/")
8819        };
8820        let mut writer = TestWriter::new();
8821        handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
8822        // No assertion needed beyond "doesn't panic and doesn't emit
8823        // empty inject markers" — the head_end / body_end paths must
8824        // treat the empty inject as no-op the same way the builder does.
8825    }
8826
8827    /// Regression for the deep-audit's Bug-6 claim. The for-loop hot-
8828    /// path optimisation (insert key once + `get_mut`-swap value
8829    /// in-place) was suspected of corrupting the outer scope when a
8830    /// nested `<for>` loop reuses the same variable name. This test
8831    /// proves the optimisation is correct under that condition by
8832    /// requiring the outer `item` to be visible before, between, and
8833    /// after the inner loop, with its value preserved across inner
8834    /// iterations.
8835    ///
8836    /// Trace through the optimisation on `outer = [A, B]`,
8837    /// `inner = [X, Y]` with both loops using `item` as the variable:
8838    ///
8839    ///   outer pre-insert "item": Null
8840    ///   iter 1: get_mut → write A
8841    ///     emit "outer:A"
8842    ///     enter inner: saved = remove("item") = Some(A)
8843    ///                  pre-insert "item": Null
8844    ///                  iter 1: write X → emit "inner:X"
8845    ///                  iter 2: write Y → emit "inner:Y"
8846    ///                  restore: insert("item", A)   ← outer's A back
8847    ///     emit "outer:A again"               ← reads A correctly
8848    ///   iter 2: get_mut → write B (overwrites the restored A,
8849    ///                              but that's correct — we're now
8850    ///                              in iter 2 of the outer loop)
8851    ///     emit "outer:B"
8852    ///     enter inner: saved = remove("item") = Some(B), …, restore B
8853    ///     emit "outer:B again"
8854    ///
8855    /// If the audit's claim were correct — that the outer's `get_mut`
8856    /// somehow held a reference past the inner loop and clobbered the
8857    /// restoration — we'd see corrupted values in the "outer:X again"
8858    /// emissions. The assertion below pins the correct sequence.
8859    #[test]
8860    fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
8861        let mut fragments = HashMap::new();
8862        fragments.insert(
8863            "index.html".to_string(),
8864            FragmentList {
8865                fragments: vec![
8866                    WebUIFragment::raw("["),
8867                    WebUIFragment::for_loop("item", "outer", "outer_body"),
8868                    WebUIFragment::raw("]"),
8869                ],
8870            },
8871        );
8872        fragments.insert(
8873            "outer_body".to_string(),
8874            FragmentList {
8875                fragments: vec![
8876                    WebUIFragment::raw("(O="),
8877                    WebUIFragment::signal("item.tag", false),
8878                    WebUIFragment::for_loop("item", "inner", "inner_body"),
8879                    WebUIFragment::raw(",O="),
8880                    WebUIFragment::signal("item.tag", false),
8881                    WebUIFragment::raw(")"),
8882                ],
8883            },
8884        );
8885        fragments.insert(
8886            "inner_body".to_string(),
8887            FragmentList {
8888                fragments: vec![
8889                    WebUIFragment::raw("[I="),
8890                    WebUIFragment::signal("item.tag", false),
8891                    WebUIFragment::raw("]"),
8892                ],
8893            },
8894        );
8895        let protocol = WebUIProtocol::new(fragments);
8896        let state = test_json!({
8897            "outer": [{"tag": "A"}, {"tag": "B"}],
8898            "inner": [{"tag": "X"}, {"tag": "Y"}],
8899        });
8900        let mut writer = TestWriter::new();
8901        handle(
8902            &protocol,
8903            &state,
8904            &RenderOptions::new("index.html", "/"),
8905            &mut writer,
8906        )
8907        .unwrap();
8908        // Expected sequence:
8909        //   outer iter 1 (item=A):
8910        //     emit "(O=A"               ← outer A before inner
8911        //     inner iter 1 (item=X) emit "[I=X]"
8912        //     inner iter 2 (item=Y) emit "[I=Y]"
8913        //     emit ",O=A)"              ← outer A AFTER inner restore
8914        //   outer iter 2 (item=B):
8915        //     emit "(O=B"
8916        //     inner iter 1 (item=X) emit "[I=X]"
8917        //     inner iter 2 (item=Y) emit "[I=Y]"
8918        //     emit ",O=B)"
8919        assert_eq!(
8920            writer.get_content(),
8921            "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
8922            "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
8923        );
8924    }
8925
8926    #[derive(Default)]
8927    struct FlushTestWriter {
8928        output: String,
8929        flushes: Vec<usize>,
8930        fail_flush: bool,
8931        fail_flush_at: Option<usize>,
8932        flush_attempts: usize,
8933        ended: bool,
8934    }
8935
8936    impl ResponseWriter for FlushTestWriter {
8937        fn write(&mut self, content: &str) -> Result<()> {
8938            self.output.push_str(content);
8939            Ok(())
8940        }
8941
8942        fn end(&mut self) -> Result<()> {
8943            self.ended = true;
8944            Ok(())
8945        }
8946    }
8947
8948    impl FlushWriter for FlushTestWriter {
8949        fn flush(&mut self) -> Result<()> {
8950            let attempt = self.flush_attempts;
8951            self.flush_attempts += 1;
8952            if self.fail_flush || self.fail_flush_at == Some(attempt) {
8953                return Err(HandlerError::ClientDisconnected);
8954            }
8955            self.flushes.push(self.output.len());
8956            Ok(())
8957        }
8958    }
8959
8960    fn streaming_protocol(with_boundaries: bool) -> Protocol {
8961        streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
8962    }
8963
8964    fn streaming_protocol_with_state_strategy(
8965        with_boundaries: bool,
8966        state_strategy: InitialStateStrategy,
8967    ) -> Protocol {
8968        let mut fragments = HashMap::new();
8969        let mut entry = vec![
8970            WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
8971            structural_fragment("head_start"),
8972            WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
8973            structural_fragment("head_end"),
8974            WebUIFragment::raw("</HEAD><body>"),
8975            structural_fragment("body_start"),
8976        ];
8977        if with_boundaries {
8978            entry.push(structural_fragment("boundary_start:0"));
8979        }
8980        entry.extend([
8981            WebUIFragment::raw("<my-counter"),
8982            structural_fragment("streaming_root:my-counter"),
8983            WebUIFragment::raw(">"),
8984            WebUIFragment::component("my-counter"),
8985            WebUIFragment::raw("</my-counter>"),
8986        ]);
8987        if with_boundaries {
8988            entry.push(structural_fragment("boundary_end:0"));
8989        }
8990        entry.extend([
8991            WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
8992            structural_fragment("body_end"),
8993            WebUIFragment::raw("</body></html>"),
8994        ]);
8995        fragments.insert("index.html".to_string(), FragmentList { fragments: entry });
8996        fragments.insert(
8997            "my-counter".to_string(),
8998            FragmentList {
8999                fragments: vec![WebUIFragment::raw("<button>Count</button>")],
9000            },
9001        );
9002
9003        let mut document = WebUIProtocol::new(fragments);
9004        document.initial_state_strategy = state_strategy as i32;
9005        document.components.insert(
9006            "my-counter".to_string(),
9007            webui_protocol::ComponentData {
9008                template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
9009                template_functions: "[function(){return true}]".to_string(),
9010                hydration_mode: StateProjectionMode::Keys as i32,
9011                hydration_keys: vec!["count".to_string()],
9012                ..Default::default()
9013            },
9014        );
9015        Protocol::new(document)
9016    }
9017
9018    #[test]
9019    fn handler_error_stays_small() {
9020        // Boxing the cold `StreamingBoundary` payload keeps `HandlerError` — and
9021        // therefore `Result<(), HandlerError>` threaded through the hot legacy
9022        // render path — down to a single `String`-sized payload plus a
9023        // discriminant word. If the boundary payload is un-boxed back to
9024        // `{ signal, reason }` it grows to two `String`s (48-byte payload) and
9025        // this fails.
9026        assert!(
9027            std::mem::size_of::<HandlerError>()
9028                <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
9029            "HandlerError grew to {} bytes",
9030            std::mem::size_of::<HandlerError>()
9031        );
9032    }
9033
9034    #[test]
9035    fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
9036        let protocol = streaming_protocol(true);
9037        let handler = WebUIHandler::with_plugin(|| {
9038            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9039        });
9040        let state = test_json!({ "count": 1, "serverOnly": "secret" });
9041        let mut writer = FlushTestWriter::default();
9042
9043        handler
9044            .render_streaming(
9045                &protocol,
9046                &state,
9047                &RenderOptions::new("index.html", "/"),
9048                &mut writer,
9049            )
9050            .unwrap();
9051
9052        assert!(writer.ended);
9053        assert_eq!(
9054            writer.flushes.len(),
9055            2,
9056            "boundary commit plus one coalesced terminal-tail flush"
9057        );
9058        let first_flush = &writer.output[..writer.flushes[0]];
9059        assert!(first_flush.contains("<!--wb:0-->"));
9060        assert!(first_flush.contains("<!--/wb:0-->"));
9061        assert!(first_flush.contains(r#"[1,0,0,0,{"inventory":"01","state":{"count":1}"#));
9062        assert!(first_flush.contains(r#""templates":{"my-counter":"#));
9063        assert!(!first_flush.contains("slow tail"));
9064        assert!(!writer.output.contains("id=\"webui-data\""));
9065        // The terminal flush commits the scriptless tail without manufacturing
9066        // another state/template projection.
9067        assert!(writer.output.contains("[1,1,3,0,{}]"));
9068        assert!(!writer.output.contains("[1,1,0,1,"));
9069        assert!(!writer.output.contains("[1,2,3,0,{}]"));
9070
9071        let marker = writer
9072            .output
9073            .find(STREAMING_MARKER)
9074            .expect("streaming marker");
9075        let attributed_head = writer
9076            .output
9077            .find("<HEAD data-shell=\"main\">")
9078            .expect("attributed mixed-case head");
9079        let authored_script = writer
9080            .output
9081            .find("src=\"/index.js\"")
9082            .expect("entry script");
9083        assert!(attributed_head < marker && marker < authored_script);
9084    }
9085
9086    #[test]
9087    fn streaming_terminal_tail_never_resends_full_state() {
9088        let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
9089        let handler = WebUIHandler::with_plugin(|| {
9090            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9091        });
9092        let mut writer = FlushTestWriter::default();
9093
9094        handler
9095            .render_streaming(
9096                &protocol,
9097                &test_json!({ "count": 1, "serverOnly": "secret" }),
9098                &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
9099                &mut writer,
9100            )
9101            .unwrap();
9102
9103        assert_eq!(
9104            writer.output.matches("serverOnly").count(),
9105            1,
9106            "full state belongs only to the interactive boundary"
9107        );
9108        assert!(writer.output.contains("[1,1,3,0,{}]"));
9109    }
9110
9111    #[test]
9112    fn static_streaming_document_uses_one_empty_terminal_record() {
9113        let fragments = HashMap::from([(
9114            "index.html".to_string(),
9115            FragmentList {
9116                fragments: vec![
9117                    WebUIFragment::raw("<html><head>"),
9118                    structural_fragment("head_start"),
9119                    structural_fragment("head_end"),
9120                    WebUIFragment::raw("</head><body>"),
9121                    structural_fragment("body_start"),
9122                    WebUIFragment::raw("<main>static</main>"),
9123                    structural_fragment("body_end"),
9124                    WebUIFragment::raw("</body></html>"),
9125                ],
9126            },
9127        )]);
9128        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9129        let mut writer = FlushTestWriter::default();
9130
9131        WebUIHandler::new()
9132            .render_streaming(
9133                &protocol,
9134                &test_json!({ "serverOnly": "secret" }),
9135                &RenderOptions::new("index.html", "/"),
9136                &mut writer,
9137            )
9138            .unwrap();
9139
9140        assert_eq!(writer.flushes.len(), 1);
9141        assert!(writer.output.contains(STREAMING_MARKER));
9142        assert!(writer.output.contains("[1,0,3,0,{}]"));
9143        assert!(!writer.output.contains("serverOnly"));
9144        assert!(!writer.output.contains("id=\"webui-data\""));
9145        assert!(!writer.output.contains("<!--wb:"));
9146    }
9147
9148    #[test]
9149    fn streaming_render_emits_nonce_and_functions_before_sentinel() {
9150        let protocol = streaming_protocol(true);
9151        let handler = WebUIHandler::with_plugin(|| {
9152            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9153        });
9154        let mut writer = FlushTestWriter::default();
9155        handler
9156            .render_streaming(
9157                &protocol,
9158                &test_json!({ "count": 1 }),
9159                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
9160                &mut writer,
9161            )
9162            .unwrap();
9163
9164        let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
9165        let envelope = writer.output[boundary_end..]
9166            .find("data-webui-boundary nonce=\"test-nonce-123\"")
9167            .map(|index| index + boundary_end)
9168            .expect("nonce-bearing envelope");
9169        let functions = writer
9170            .output
9171            .find("templateFns")
9172            .expect("function side channel");
9173        let sentinel = writer
9174            .output
9175            .find("<webui-hydrate>")
9176            .expect("hydration sentinel");
9177        assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
9178        assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
9179    }
9180
9181    #[test]
9182    fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
9183        fn protocol(signals: &[&str]) -> Protocol {
9184            let mut protocol_signals = Vec::with_capacity(signals.len() + 1);
9185            protocol_signals.push(structural_fragment("head_start"));
9186            protocol_signals.extend(signals.iter().map(|signal| structural_fragment(*signal)));
9187            let fragments = HashMap::from([(
9188                "index.html".to_string(),
9189                FragmentList {
9190                    fragments: protocol_signals,
9191                },
9192            )]);
9193            Protocol::new(WebUIProtocol::new(fragments))
9194        }
9195
9196        let handler = WebUIHandler::new();
9197        let mut writer = FlushTestWriter::default();
9198        let result = handler.render_streaming(
9199            &protocol(&["boundary_start:1"]),
9200            &test_json!({}),
9201            &RenderOptions::new("index.html", "/"),
9202            &mut writer,
9203        );
9204        match result {
9205            Err(HandlerError::StreamingBoundary(err)) => {
9206                assert_eq!(err.signal, "boundary_start:1");
9207                assert!(err.reason.contains("expected boundary sequence 0"));
9208            }
9209            other => panic!("expected ordered-boundary error, got {other:?}"),
9210        }
9211
9212        let mut writer = FlushTestWriter::default();
9213        let result = handler.render_streaming(
9214            &protocol(&["boundary_start:0", "boundary_start:1"]),
9215            &test_json!({}),
9216            &RenderOptions::new("index.html", "/"),
9217            &mut writer,
9218        );
9219        assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
9220    }
9221
9222    #[test]
9223    fn streaming_render_propagates_boundary_flush_disconnect() {
9224        let protocol = streaming_protocol(true);
9225        let handler = WebUIHandler::new();
9226        let mut writer = FlushTestWriter {
9227            fail_flush: true,
9228            ..FlushTestWriter::default()
9229        };
9230        let result = handler.render_streaming(
9231            &protocol,
9232            &test_json!({}),
9233            &RenderOptions::new("index.html", "/"),
9234            &mut writer,
9235        );
9236        assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
9237        assert!(!writer.ended);
9238    }
9239
9240    #[test]
9241    fn streaming_render_requires_structural_head_start_before_writing() {
9242        let fragments = HashMap::from([(
9243            "index.html".to_string(),
9244            FragmentList {
9245                fragments: vec![
9246                    WebUIFragment::raw(
9247                        "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
9248                    ),
9249                    structural_fragment("head_end"),
9250                    WebUIFragment::raw("</HEAD><body>"),
9251                    structural_fragment("body_start"),
9252                    structural_fragment("body_end"),
9253                ],
9254            },
9255        )]);
9256        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9257        let mut writer = FlushTestWriter::default();
9258
9259        let result = WebUIHandler::new().render_streaming(
9260            &protocol,
9261            &test_json!({}),
9262            &RenderOptions::new("index.html", "/"),
9263            &mut writer,
9264        );
9265
9266        assert!(matches!(
9267            result,
9268            Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
9269        ));
9270        assert!(
9271            writer.output.is_empty(),
9272            "preflight must fail before output"
9273        );
9274        assert!(writer.flushes.is_empty());
9275        assert!(!writer.ended);
9276    }
9277
9278    #[test]
9279    fn streaming_render_rejects_duplicate_head_start() {
9280        let fragments = HashMap::from([(
9281            "index.html".to_string(),
9282            FragmentList {
9283                fragments: vec![
9284                    WebUIFragment::raw("<html><head>"),
9285                    structural_fragment("head_start"),
9286                    structural_fragment("head_start"),
9287                    structural_fragment("head_end"),
9288                    WebUIFragment::raw("</head><body>"),
9289                    structural_fragment("body_start"),
9290                    structural_fragment("body_end"),
9291                ],
9292            },
9293        )]);
9294        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9295        let mut writer = FlushTestWriter::default();
9296
9297        let result = WebUIHandler::new().render_streaming(
9298            &protocol,
9299            &test_json!({}),
9300            &RenderOptions::new("index.html", "/"),
9301            &mut writer,
9302        );
9303
9304        assert!(matches!(
9305            result,
9306            Err(HandlerError::DuplicateStreamingHeadStart)
9307        ));
9308        assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
9309        assert!(writer.flushes.is_empty());
9310    }
9311
9312    #[test]
9313    fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
9314        let handler = WebUIHandler::with_plugin(|| {
9315            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9316        });
9317        let state = test_json!({
9318            "count": 1,
9319            "head_start": "must not render",
9320            "boundary_start:0": "must not render",
9321            "boundary_end:0": "must not render",
9322        });
9323        let mut with_boundaries = TestWriter::new();
9324        let mut without_boundaries = TestWriter::new();
9325        handler
9326            .render(
9327                &streaming_protocol(true),
9328                &state,
9329                &RenderOptions::new("index.html", "/"),
9330                &mut with_boundaries,
9331            )
9332            .unwrap();
9333        handler
9334            .render(
9335                &streaming_protocol(false),
9336                &state,
9337                &RenderOptions::new("index.html", "/"),
9338                &mut without_boundaries,
9339            )
9340            .unwrap();
9341        assert_eq!(
9342            with_boundaries.get_content(),
9343            without_boundaries.get_content()
9344        );
9345    }
9346
9347    #[test]
9348    fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
9349        for source in [
9350            r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
9351            r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
9352        ] {
9353            let mut parser = HtmlParser::new();
9354            parser
9355                .parse("index.html", source)
9356                .expect("parse head fixture");
9357            let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
9358            let mut writer = TestWriter::new();
9359            WebUIHandler::new()
9360                .render(
9361                    &protocol,
9362                    &test_json!({ "theme": "light" }),
9363                    &RenderOptions::new("index.html", "/"),
9364                    &mut writer,
9365                )
9366                .unwrap();
9367
9368            assert_eq!(
9369                writer.get_content(),
9370                "<html><head><title>T</title></head><body>x</body></html>"
9371            );
9372        }
9373    }
9374
9375    #[test]
9376    fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
9377        let source =
9378            r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
9379        let mut parser = HtmlParser::new();
9380        parser.parse("index.html", source).expect("parse fixture");
9381        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
9382
9383        let mut ordinary = TestWriter::new();
9384        WebUIHandler::new()
9385            .render(
9386                &protocol,
9387                &test_json!({}),
9388                &RenderOptions::new("index.html", "/"),
9389                &mut ordinary,
9390            )
9391            .unwrap();
9392        assert_eq!(ordinary.get_content(), source);
9393
9394        let mut streaming = FlushTestWriter::default();
9395        WebUIHandler::new()
9396            .render_streaming(
9397                &protocol,
9398                &test_json!({}),
9399                &RenderOptions::new("index.html", "/"),
9400                &mut streaming,
9401            )
9402            .unwrap();
9403        assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
9404        assert!(streaming.output.contains("</HEAD><BODY>x"));
9405        let opening = streaming.output.find("<HEAD").expect("mixed-case head");
9406        let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
9407        let title = streaming.output.find("<title>").expect("title");
9408        assert!(opening < marker && marker < title);
9409        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
9410    }
9411
9412    #[test]
9413    fn ordinary_render_preserves_legacy_unnamespaced_structural_hooks() {
9414        let fragments = HashMap::from([(
9415            "index.html".to_string(),
9416            FragmentList {
9417                fragments: vec![
9418                    WebUIFragment::raw("<html><head>"),
9419                    WebUIFragment::signal("head_end".to_string(), true),
9420                    WebUIFragment::raw("</head><body>"),
9421                    WebUIFragment::signal("body_start".to_string(), true),
9422                    WebUIFragment::raw("<p>legacy</p>"),
9423                    WebUIFragment::signal("body_end".to_string(), true),
9424                    WebUIFragment::raw("</body></html>"),
9425                ],
9426            },
9427        )]);
9428        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9429        let handler = WebUIHandler::with_plugin(|| {
9430            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9431        });
9432        let options = RenderOptions::new("index.html", "/").with_nonce("test-legacy-nonce");
9433        let mut writer = TestWriter::new();
9434
9435        handler
9436            .render(&protocol, &test_json!({}), &options, &mut writer)
9437            .unwrap();
9438
9439        let html = writer.get_content();
9440        assert!(html.contains(r#"<meta name="webui-nonce" content="test-legacy-nonce">"#));
9441        assert!(html.contains(r#"<script type="application/json" id="webui-data""#));
9442        assert!(html.contains("<p>legacy</p>"));
9443
9444        let mut streaming = FlushTestWriter::default();
9445        assert!(matches!(
9446            handler.render_streaming(
9447                &protocol,
9448                &test_json!({}),
9449                &RenderOptions::new("index.html", "/"),
9450                &mut streaming,
9451            ),
9452            Err(HandlerError::MissingStreamingHeadStart { .. })
9453        ));
9454    }
9455
9456    #[test]
9457    fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
9458        let source = concat!(
9459            "<html><head></head><body>",
9460            "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
9461            "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
9462            "</body></html>",
9463        );
9464        let state = test_json!({
9465            "head_start": "hs",
9466            "head_end": "he",
9467            "body_start": "bs",
9468            "body_end": "be",
9469            "boundary_start:0": "b0s",
9470            "boundary_end:0": "b0e",
9471            "streaming_root:forged": "root",
9472        });
9473        let mut parser = HtmlParser::new();
9474        parser.parse("index.html", source).expect("parse fixture");
9475        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
9476        let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
9477
9478        let mut ordinary = TestWriter::new();
9479        WebUIHandler::new()
9480            .render(
9481                &protocol,
9482                &state,
9483                &RenderOptions::new("index.html", "/"),
9484                &mut ordinary,
9485            )
9486            .unwrap();
9487        assert_eq!(ordinary.get_content(), expected);
9488
9489        let mut streaming = FlushTestWriter::default();
9490        WebUIHandler::new()
9491            .render_streaming(
9492                &protocol,
9493                &state,
9494                &RenderOptions::new("index.html", "/"),
9495                &mut streaming,
9496            )
9497            .unwrap();
9498        assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
9499        assert!(!streaming.output.contains("<!--wb:"));
9500        assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
9501        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
9502    }
9503
9504    #[test]
9505    fn streaming_rejects_structural_signals_after_terminal() {
9506        let fragments = HashMap::from([(
9507            "index.html".to_string(),
9508            FragmentList {
9509                fragments: vec![
9510                    WebUIFragment::raw("<html><head>"),
9511                    structural_fragment("head_start"),
9512                    structural_fragment("head_end"),
9513                    WebUIFragment::raw("</head><body>"),
9514                    structural_fragment("body_start"),
9515                    structural_fragment("body_end"),
9516                    WebUIFragment::raw("</body>"),
9517                    structural_fragment("boundary_start:0"),
9518                    WebUIFragment::raw("</html>"),
9519                ],
9520            },
9521        )]);
9522        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9523        let mut streaming = FlushTestWriter::default();
9524        let result = WebUIHandler::new().render_streaming(
9525            &protocol,
9526            &test_json!({}),
9527            &RenderOptions::new("index.html", "/"),
9528            &mut streaming,
9529        );
9530
9531        match result {
9532            Err(HandlerError::StreamingBoundary(error)) => {
9533                assert_eq!(error.signal, "boundary_start:0");
9534                assert!(error.reason.contains("after the body_end terminal record"));
9535            }
9536            other => panic!("expected post-terminal rejection, got {other:?}"),
9537        }
9538        assert!(streaming.output.is_empty());
9539        assert!(!streaming.output.contains("<!--wb:0-->"));
9540
9541        let mut ordinary = TestWriter::new();
9542        WebUIHandler::new()
9543            .render(
9544                &protocol,
9545                &test_json!({}),
9546                &RenderOptions::new("index.html", "/"),
9547                &mut ordinary,
9548            )
9549            .unwrap();
9550        assert_eq!(
9551            ordinary.get_content(),
9552            "<html><head></head><body></body></html>"
9553        );
9554    }
9555
9556    /// Build a streaming protocol with one boundary per `hosts` entry. Each
9557    /// boundary wraps a component host whose opening tag is split so the
9558    /// compiler-owned `streaming_root:<tag>` signal (optionally emitted) lands
9559    /// inside the tag, exactly as `HtmlParser` produces. Components `comp-a` and
9560    /// `comp-b` carry disjoint templates and disjoint hydration keys.
9561    fn disjoint_streaming_protocol_ext(hosts: &[&str], emit_root_signal: bool) -> Protocol {
9562        let mut fragments = HashMap::new();
9563        let mut entry = vec![
9564            WebUIFragment::raw("<!DOCTYPE html><html><head>"),
9565            structural_fragment("head_start"),
9566            structural_fragment("head_end"),
9567            WebUIFragment::raw("</head><body>"),
9568            structural_fragment("body_start"),
9569        ];
9570        for (sequence, host) in hosts.iter().enumerate() {
9571            entry.push(structural_fragment(format!("boundary_start:{sequence}")));
9572            entry.push(WebUIFragment::raw(format!("<{host}")));
9573            if emit_root_signal {
9574                entry.push(structural_fragment(format!("streaming_root:{host}")));
9575            }
9576            entry.push(WebUIFragment::raw(">"));
9577            entry.push(WebUIFragment::component(*host));
9578            entry.push(WebUIFragment::raw(format!("</{host}>")));
9579            entry.push(structural_fragment(format!("boundary_end:{sequence}")));
9580        }
9581        entry.push(structural_fragment("body_end"));
9582        entry.push(WebUIFragment::raw("</body></html>"));
9583        fragments.insert("index.html".to_string(), FragmentList { fragments: entry });
9584        fragments.insert(
9585            "comp-a".to_string(),
9586            FragmentList {
9587                fragments: vec![WebUIFragment::raw("<b>A</b>")],
9588            },
9589        );
9590        fragments.insert(
9591            "comp-b".to_string(),
9592            FragmentList {
9593                fragments: vec![WebUIFragment::raw("<b>B</b>")],
9594            },
9595        );
9596
9597        let mut document = WebUIProtocol::new(fragments);
9598        document.initial_state_strategy = InitialStateStrategy::Components as i32;
9599        document.streaming_boundaries.insert(
9600            "index.html".to_string(),
9601            webui_protocol::StreamingBoundaryList {
9602                names: (0..hosts.len())
9603                    .map(|index| format!("boundary-{index}"))
9604                    .collect(),
9605            },
9606        );
9607        document.components.insert(
9608            "comp-a".to_string(),
9609            webui_protocol::ComponentData {
9610                template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
9611                hydration_mode: StateProjectionMode::Keys as i32,
9612                hydration_keys: vec!["a_count".to_string()],
9613                ..Default::default()
9614            },
9615        );
9616        document.components.insert(
9617            "comp-b".to_string(),
9618            webui_protocol::ComponentData {
9619                template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
9620                hydration_mode: StateProjectionMode::Keys as i32,
9621                hydration_keys: vec!["b_count".to_string()],
9622                ..Default::default()
9623            },
9624        );
9625        Protocol::new(document)
9626    }
9627
9628    fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
9629        disjoint_streaming_protocol_ext(hosts, true)
9630    }
9631
9632    fn streaming_plan_validation_protocol(signals: &[&str], names: &[&str]) -> Protocol {
9633        let mut entry = vec![
9634            WebUIFragment::raw("<html><head>"),
9635            structural_fragment("head_start"),
9636            structural_fragment("head_end"),
9637            WebUIFragment::raw("</head><body>"),
9638            structural_fragment("body_start"),
9639        ];
9640        entry.extend(signals.iter().map(structural_fragment));
9641        entry.extend([
9642            structural_fragment("body_end"),
9643            WebUIFragment::raw("</body></html>"),
9644        ]);
9645
9646        let mut document = WebUIProtocol::new(HashMap::from([(
9647            "index.html".to_string(),
9648            FragmentList { fragments: entry },
9649        )]));
9650        document.streaming_boundaries.insert(
9651            "index.html".to_string(),
9652            webui_protocol::StreamingBoundaryList {
9653                names: names.iter().map(|name| (*name).to_string()).collect(),
9654            },
9655        );
9656        Protocol::new(document)
9657    }
9658
9659    #[test]
9660    fn streaming_response_rejects_cached_malformed_boundary_plan_before_writing() {
9661        let protocol =
9662            streaming_plan_validation_protocol(&["boundary_start:0", "boundary_end:1"], &["one"]);
9663        let handler = WebUIHandler::new();
9664        let mut writer = FlushTestWriter::default();
9665        let options = RenderOptions::new("index.html", "/");
9666
9667        let error = match handler.stream_response(&protocol, &options, &mut writer) {
9668            Ok(_) => panic!("malformed boundary plan unexpectedly opened a response"),
9669            Err(error) => error,
9670        };
9671
9672        assert!(
9673            matches!(error, HandlerError::StreamingBoundary(_)),
9674            "error: {error}"
9675        );
9676        assert!(writer.output.is_empty());
9677        assert!(writer.flushes.is_empty());
9678    }
9679
9680    #[test]
9681    fn streaming_response_rejects_boundary_name_count_mismatch_before_writing() {
9682        let protocol = streaming_plan_validation_protocol(
9683            &["boundary_start:0", "boundary_end:0"],
9684            &["one", "two"],
9685        );
9686        let handler = WebUIHandler::new();
9687        let mut writer = FlushTestWriter::default();
9688        let options = RenderOptions::new("index.html", "/");
9689
9690        let error = match handler.stream_response(&protocol, &options, &mut writer) {
9691            Ok(_) => panic!("mismatched boundary names unexpectedly opened a response"),
9692            Err(error) => error,
9693        };
9694
9695        assert!(
9696            matches!(error, HandlerError::Invariant(_)),
9697            "error: {error}"
9698        );
9699        assert!(writer.output.is_empty());
9700        assert!(writer.flushes.is_empty());
9701    }
9702
9703    #[test]
9704    fn streaming_response_interleaves_projected_updates_with_later_boundaries() {
9705        let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b"]);
9706        let handler = WebUIHandler::with_plugin(|| {
9707            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9708        });
9709        let mut writer = FlushTestWriter::default();
9710        let options = RenderOptions::new("index.html", "/");
9711        let mut response = handler
9712            .stream_response(&protocol, &options, &mut writer)
9713            .unwrap();
9714        let first = response.boundary("boundary-0").unwrap();
9715        let second = response.boundary("boundary-1").unwrap();
9716
9717        response.write_shell(&test_json!({})).unwrap();
9718        response
9719            .write_boundary(
9720                first,
9721                &test_json!({ "a_count": 1, "serverOnly": "secret" }),
9722                BoundaryMode::Updatable,
9723            )
9724            .unwrap();
9725        response
9726            .update(first, &test_json!({ "a_count": 7, "serverOnly": "secret" }))
9727            .unwrap();
9728        response
9729            .write_boundary(
9730                second,
9731                &test_json!({ "b_count": 2, "serverOnly": "secret" }),
9732                BoundaryMode::Final,
9733            )
9734            .unwrap();
9735        response.finish(&test_json!({})).unwrap();
9736
9737        assert_eq!(writer.flushes.len(), 5);
9738        assert!(writer.output.contains(r#"[1,0,1,0,{"#));
9739        assert!(writer.output.contains(r#""state":{"a_count":1}"#));
9740        assert!(writer.output.contains(r#"[1,1,2,0,{"a_count":7}]"#));
9741        assert!(writer.output.contains(r#"[1,2,0,1,{"#));
9742        assert!(writer.output.contains(r#""state":{"b_count":2}"#));
9743        assert!(writer.output.contains("[1,3,3,0,{}]"));
9744        assert_eq!(writer.output.matches("serverOnly").count(), 0);
9745
9746        let update_start = writer.output.find("[1,1,2,0,").unwrap();
9747        let update_end = writer.output[update_start..]
9748            .find("</script>")
9749            .map(|offset| update_start + offset)
9750            .unwrap();
9751        let update = &writer.output[update_start..update_end];
9752        assert!(!update.contains("inventory"));
9753        assert!(!update.contains("templates"));
9754    }
9755
9756    #[test]
9757    fn owned_streaming_session_matches_borrowed_response_bytes() {
9758        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a", "comp-b"]));
9759        let handler = Arc::new(WebUIHandler::with_plugin(|| {
9760            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9761        }));
9762
9763        let mut writer = FlushTestWriter::default();
9764        let options = RenderOptions::new("index.html", "/");
9765        let mut response = handler
9766            .stream_response(&protocol, &options, &mut writer)
9767            .unwrap();
9768        let first = response.boundary("boundary-0").unwrap();
9769        let second = response.boundary("boundary-1").unwrap();
9770        response.write_shell(&test_json!({})).unwrap();
9771        response
9772            .write_boundary(
9773                first,
9774                &test_json!({ "a_count": 1 }),
9775                BoundaryMode::Updatable,
9776            )
9777            .unwrap();
9778        response
9779            .update(first, &test_json!({ "a_count": 7 }))
9780            .unwrap();
9781        response
9782            .write_boundary(second, &test_json!({ "b_count": 2 }), BoundaryMode::Final)
9783            .unwrap();
9784        response.finish(&test_json!({})).unwrap();
9785
9786        let mut session = StreamingSession::new(
9787            Arc::clone(&handler),
9788            Arc::clone(&protocol),
9789            SessionOptions::new("index.html", "/"),
9790        )
9791        .unwrap();
9792        let session_first = session.boundary("boundary-0").unwrap();
9793        let session_second = session.boundary("boundary-1").unwrap();
9794        assert_eq!(session_first, first);
9795        assert_eq!(session_second, second);
9796        assert_eq!(session.boundary_count(), 2);
9797
9798        let chunks: Vec<Vec<u8>> = vec![
9799            session.write_shell(&test_json!({})).unwrap(),
9800            session
9801                .write_boundary(
9802                    session_first,
9803                    &test_json!({ "a_count": 1 }),
9804                    BoundaryMode::Updatable,
9805                )
9806                .unwrap(),
9807            session
9808                .update(session_first, &test_json!({ "a_count": 7 }))
9809                .unwrap(),
9810            session
9811                .write_boundary(
9812                    session_second,
9813                    &test_json!({ "b_count": 2 }),
9814                    BoundaryMode::Final,
9815                )
9816                .unwrap(),
9817            session.finish(&test_json!({})).unwrap(),
9818        ];
9819
9820        // One chunk per host call, matching the borrowed path's flush count.
9821        assert_eq!(chunks.len(), writer.flushes.len());
9822        assert!(session.is_finished());
9823        let joined = String::from_utf8(chunks.concat()).unwrap();
9824        assert_eq!(joined, writer.output);
9825    }
9826
9827    #[test]
9828    fn owned_streaming_session_rejects_use_after_finish() {
9829        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"]));
9830        let handler = Arc::new(WebUIHandler::new());
9831        let mut session =
9832            StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
9833                .unwrap();
9834        let boundary = session.boundary("boundary-0").unwrap();
9835        session.write_shell(&test_json!({})).unwrap();
9836        session
9837            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
9838            .unwrap();
9839        session.finish(&test_json!({})).unwrap();
9840
9841        let error = session.write_shell(&test_json!({})).unwrap_err();
9842        assert!(error.to_string().contains("already finished"));
9843        assert!(session.finish(&test_json!({})).is_err());
9844    }
9845
9846    #[test]
9847    fn owned_streaming_session_surfaces_unknown_boundary_names() {
9848        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"]));
9849        let handler = Arc::new(WebUIHandler::new());
9850        let session =
9851            StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
9852                .unwrap();
9853        let error = session.boundary("boundary-O").unwrap_err().to_string();
9854        assert!(error.contains("boundary-0"));
9855    }
9856
9857    #[test]
9858    fn owned_streaming_session_stays_usable_after_a_rejected_call() {
9859        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"]));
9860        let handler = Arc::new(WebUIHandler::new());
9861        let mut session =
9862            StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
9863                .unwrap();
9864        let boundary = session.boundary("boundary-0").unwrap();
9865        session.write_shell(&test_json!({})).unwrap();
9866        // Rejected before any byte is written, so the session is not poisoned.
9867        assert!(session.update(boundary, &test_json!({ "a": 1 })).is_err());
9868        assert!(!session.is_finished());
9869        session
9870            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
9871            .unwrap();
9872        session.finish(&test_json!({})).unwrap();
9873    }
9874
9875    #[test]
9876    fn owned_streaming_session_recovers_from_a_rejected_finish() {
9877        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a", "comp-b"]));
9878        let handler = Arc::new(WebUIHandler::new());
9879        let mut session =
9880            StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
9881                .unwrap();
9882        let first = session.boundary("boundary-0").unwrap();
9883        let second = session.boundary("boundary-1").unwrap();
9884        session.write_shell(&test_json!({})).unwrap();
9885        session
9886            .write_boundary(first, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
9887            .unwrap();
9888
9889        // Rejected before any byte is written, so the response must survive.
9890        let error = session.finish(&test_json!({})).unwrap_err();
9891        assert!(
9892            error
9893                .to_string()
9894                .contains("every boundary must be committed"),
9895            "unexpected error: {error}"
9896        );
9897        assert!(!session.is_finished());
9898
9899        session
9900            .write_boundary(second, &test_json!({ "b_count": 1 }), BoundaryMode::Final)
9901            .unwrap();
9902        let tail = session.finish(&test_json!({})).unwrap();
9903        assert!(!tail.is_empty());
9904        assert!(session.is_finished());
9905    }
9906
9907    #[test]
9908    fn owned_streaming_session_rejects_finish_before_the_shell() {
9909        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"]));
9910        let handler = Arc::new(WebUIHandler::new());
9911        let mut session =
9912            StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
9913                .unwrap();
9914        let boundary = session.boundary("boundary-0").unwrap();
9915
9916        let error = session.finish(&test_json!({})).unwrap_err();
9917        assert!(
9918            error.to_string().contains("write_shell must be called"),
9919            "unexpected error: {error}"
9920        );
9921        assert!(!session.is_finished());
9922
9923        session.write_shell(&test_json!({})).unwrap();
9924        session
9925            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
9926            .unwrap();
9927        session.finish(&test_json!({})).unwrap();
9928        assert!(session.is_finished());
9929    }
9930
9931    #[test]
9932    fn streaming_response_is_poisoned_after_partial_boundary_flush_failure() {
9933        let protocol = disjoint_streaming_protocol(&["comp-a"]);
9934        let handler = WebUIHandler::new();
9935        let mut writer = FlushTestWriter {
9936            fail_flush_at: Some(1),
9937            ..FlushTestWriter::default()
9938        };
9939        let options = RenderOptions::new("index.html", "/");
9940        let mut response = handler
9941            .stream_response(&protocol, &options, &mut writer)
9942            .unwrap();
9943        let boundary = response.boundary("boundary-0").unwrap();
9944        response.write_shell(&test_json!({})).unwrap();
9945
9946        let first_error = response
9947            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
9948            .unwrap_err();
9949        assert!(matches!(first_error, HandlerError::ClientDisconnected));
9950
9951        let retry_error = response
9952            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
9953            .unwrap_err();
9954        assert!(
9955            retry_error
9956                .to_string()
9957                .contains("unusable after a previous render or transport failure"),
9958            "error: {retry_error}"
9959        );
9960
9961        drop(response);
9962        assert_eq!(writer.output.matches("<!--wb:0-->").count(), 1);
9963        assert_eq!(writer.flushes.len(), 1);
9964        assert!(!writer.ended);
9965    }
9966
9967    #[test]
9968    fn streaming_response_rejects_updates_to_final_boundaries() {
9969        let protocol = disjoint_streaming_protocol(&["comp-a"]);
9970        let handler = WebUIHandler::new();
9971        let mut writer = FlushTestWriter::default();
9972        let options = RenderOptions::new("index.html", "/");
9973        let mut response = handler
9974            .stream_response(&protocol, &options, &mut writer)
9975            .unwrap();
9976        let boundary = response.boundary("boundary-0").unwrap();
9977        response.write_shell(&test_json!({})).unwrap();
9978        response
9979            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
9980            .unwrap();
9981
9982        let error = response
9983            .update(boundary, &test_json!({ "a_count": 2 }))
9984            .unwrap_err();
9985        assert!(
9986            error.to_string().contains("committed as final"),
9987            "error: {error}"
9988        );
9989    }
9990
9991    #[test]
9992    fn streaming_response_rejects_non_object_updates_before_writing() {
9993        let protocol = disjoint_streaming_protocol(&["comp-a"]);
9994        let handler = WebUIHandler::new();
9995        let mut writer = FlushTestWriter::default();
9996        let options = RenderOptions::new("index.html", "/");
9997        let mut response = handler
9998            .stream_response(&protocol, &options, &mut writer)
9999            .unwrap();
10000        let boundary = response.boundary("boundary-0").unwrap();
10001        response.write_shell(&test_json!({})).unwrap();
10002        response
10003            .write_boundary(
10004                boundary,
10005                &test_json!({ "a_count": 1 }),
10006                BoundaryMode::Updatable,
10007            )
10008            .unwrap();
10009        let error = response
10010            .update(boundary, &test_json!("invalid"))
10011            .unwrap_err();
10012        assert!(error.to_string().contains("JSON object"), "error: {error}");
10013
10014        response
10015            .update(boundary, &test_json!({ "a_count": 2 }))
10016            .unwrap();
10017        response.finish(&test_json!({})).unwrap();
10018        assert_eq!(writer.flushes.len(), 4);
10019        assert_eq!(writer.output.matches("data-webui-boundary").count(), 3);
10020        assert!(writer.output.contains(r#"[1,1,2,0,{"a_count":2}]"#));
10021    }
10022
10023    #[test]
10024    fn streaming_response_unknown_boundary_suggests_valid_name() {
10025        let protocol = disjoint_streaming_protocol(&["comp-a"]);
10026        let handler = WebUIHandler::new();
10027        let mut writer = FlushTestWriter::default();
10028        let options = RenderOptions::new("index.html", "/");
10029        let response = handler
10030            .stream_response(&protocol, &options, &mut writer)
10031            .unwrap();
10032
10033        let error = response.boundary("boundry-0").unwrap_err();
10034        assert!(
10035            error.to_string().contains("did you mean `boundary-0`?"),
10036            "error: {error}"
10037        );
10038    }
10039
10040    fn streaming_root_validation_protocol(
10041        mut host_fragments: Vec<WebUIFragment>,
10042        tail: Vec<WebUIFragment>,
10043    ) -> Protocol {
10044        let mut entry = vec![
10045            WebUIFragment::raw("<html><head>"),
10046            structural_fragment("head_start"),
10047            structural_fragment("head_end"),
10048            WebUIFragment::raw("</head><body>"),
10049            structural_fragment("body_start"),
10050            structural_fragment("boundary_start:0"),
10051        ];
10052        entry.append(&mut host_fragments);
10053        entry.extend(tail);
10054        let fragments = HashMap::from([
10055            ("index.html".to_string(), FragmentList { fragments: entry }),
10056            (
10057                "comp-a".to_string(),
10058                FragmentList {
10059                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
10060                },
10061            ),
10062            (
10063                "comp-b".to_string(),
10064                FragmentList {
10065                    fragments: vec![WebUIFragment::raw("<b>B</b>")],
10066                },
10067            ),
10068        ]);
10069        Protocol::new(WebUIProtocol::new(fragments))
10070    }
10071
10072    fn completed_streaming_tail() -> Vec<WebUIFragment> {
10073        vec![
10074            WebUIFragment::raw("</comp-a>"),
10075            structural_fragment("boundary_end:0"),
10076            structural_fragment("body_end"),
10077            WebUIFragment::raw("</body></html>"),
10078        ]
10079    }
10080
10081    fn assert_streaming_root_error(
10082        protocol: &Protocol,
10083        expected_signal: &str,
10084        expected_reason: &str,
10085    ) {
10086        let mut writer = FlushTestWriter::default();
10087        let result = WebUIHandler::new().render_streaming(
10088            protocol,
10089            &test_json!({}),
10090            &RenderOptions::new("index.html", "/"),
10091            &mut writer,
10092        );
10093        match result {
10094            Err(HandlerError::StreamingBoundary(error)) => {
10095                assert_eq!(error.signal, expected_signal);
10096                assert!(
10097                    error.reason.contains(expected_reason),
10098                    "reason: {}",
10099                    error.reason
10100                );
10101            }
10102            other => panic!("expected streaming-root rejection, got {other:?}"),
10103        }
10104    }
10105
10106    #[test]
10107    fn streaming_root_signal_injects_data_ws_inside_boundary() {
10108        let protocol = disjoint_streaming_protocol(&["comp-a"]);
10109        let handler = WebUIHandler::with_plugin(|| {
10110            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10111        });
10112        let mut writer = FlushTestWriter::default();
10113        handler
10114            .render_streaming(
10115                &protocol,
10116                &test_json!({ "a_count": 1 }),
10117                &RenderOptions::new("index.html", "/"),
10118                &mut writer,
10119            )
10120            .unwrap();
10121        // The parser-owned signal is consumed to inject exactly ` data-ws`
10122        // inside the host's opening tag, before the custom element upgrades.
10123        assert!(
10124            writer.output.contains("<comp-a data-ws>"),
10125            "streamed host must carry data-ws: {}",
10126            writer.output
10127        );
10128    }
10129
10130    #[test]
10131    fn streaming_root_signal_preserves_ordinary_output_bytes() {
10132        // Ordinary rendering ignores `streaming_root` byte-for-byte: identical
10133        // output with and without the signal, and never a `data-ws` attribute.
10134        let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true);
10135        let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false);
10136        let state = test_json!({ "a_count": 1, "b_count": 2 });
10137        let plugin = || {
10138            WebUIHandler::with_plugin(
10139                || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
10140            )
10141        };
10142        let mut with_writer = TestWriter::new();
10143        let mut without_writer = TestWriter::new();
10144        plugin()
10145            .render(
10146                &with_signal,
10147                &state,
10148                &RenderOptions::new("index.html", "/"),
10149                &mut with_writer,
10150            )
10151            .unwrap();
10152        plugin()
10153            .render(
10154                &without_signal,
10155                &state,
10156                &RenderOptions::new("index.html", "/"),
10157                &mut without_writer,
10158            )
10159            .unwrap();
10160        // The rendered DOM (everything up to the inert data block) is
10161        // deterministic and is where a leaked `data-ws` would appear. The
10162        // ordinary template map is HashSet-ordered, so compare the DOM prefix
10163        // for byte identity rather than the whole document.
10164        let dom_prefix = |content: &str| -> String {
10165            content
10166                .split_once(r#"<script type="application/json" id="webui-data""#)
10167                .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
10168        };
10169        assert_eq!(
10170            dom_prefix(&with_writer.get_content()),
10171            dom_prefix(&without_writer.get_content())
10172        );
10173        assert!(!with_writer.get_content().contains("data-ws"));
10174        assert!(with_writer.get_content().contains("<comp-a>"));
10175    }
10176
10177    #[test]
10178    fn streaming_component_without_root_signal_is_rejected() {
10179        let protocol = streaming_root_validation_protocol(
10180            vec![
10181                WebUIFragment::raw("<comp-a>"),
10182                WebUIFragment::component("comp-a"),
10183            ],
10184            completed_streaming_tail(),
10185        );
10186
10187        assert_streaming_root_error(
10188            &protocol,
10189            "streaming_root:comp-a",
10190            "has no matching compiler-owned root signal",
10191        );
10192    }
10193
10194    #[test]
10195    fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
10196        let duplicate = streaming_root_validation_protocol(
10197            vec![
10198                WebUIFragment::raw("<comp-a"),
10199                structural_fragment("streaming_root:comp-a"),
10200                structural_fragment("streaming_root:comp-a"),
10201                WebUIFragment::raw(">"),
10202                WebUIFragment::component("comp-a"),
10203            ],
10204            completed_streaming_tail(),
10205        );
10206        assert_streaming_root_error(&duplicate, "streaming_root:comp-a", "is misplaced");
10207
10208        let after_tag_close = streaming_root_validation_protocol(
10209            vec![
10210                WebUIFragment::raw("<comp-a>"),
10211                structural_fragment("streaming_root:comp-a"),
10212                WebUIFragment::component("comp-a"),
10213            ],
10214            completed_streaming_tail(),
10215        );
10216        assert_streaming_root_error(&after_tag_close, "streaming_root:comp-a", "is misplaced");
10217
10218        let detached_from_opening = streaming_root_validation_protocol(
10219            vec![
10220                WebUIFragment::raw("not an opening tag"),
10221                structural_fragment("streaming_root:comp-a"),
10222                WebUIFragment::raw(">"),
10223                WebUIFragment::component("comp-a"),
10224            ],
10225            completed_streaming_tail(),
10226        );
10227        assert_streaming_root_error(
10228            &detached_from_opening,
10229            "streaming_root:comp-a",
10230            "unclosed component opening-tag close",
10231        );
10232    }
10233
10234    #[test]
10235    fn mismatched_streaming_root_signal_is_rejected() {
10236        let protocol = streaming_root_validation_protocol(
10237            vec![
10238                WebUIFragment::raw("<comp-a"),
10239                structural_fragment("streaming_root:comp-b"),
10240                WebUIFragment::raw(">"),
10241                WebUIFragment::component("comp-a"),
10242            ],
10243            completed_streaming_tail(),
10244        );
10245
10246        assert_streaming_root_error(
10247            &protocol,
10248            "streaming_root:comp-b",
10249            "opening host renders component <comp-a>",
10250        );
10251    }
10252
10253    #[test]
10254    fn pending_streaming_root_is_rejected_at_structural_ends() {
10255        let protocol = streaming_root_validation_protocol(
10256            vec![
10257                WebUIFragment::raw("<comp-a"),
10258                structural_fragment("streaming_root:comp-a"),
10259                WebUIFragment::raw(">"),
10260            ],
10261            vec![structural_fragment("boundary_end:0")],
10262        );
10263        assert_streaming_root_error(&protocol, "streaming_root:comp-a", "is misplaced");
10264    }
10265
10266    #[test]
10267    fn streaming_root_outside_boundary_is_rejected() {
10268        let fragments = HashMap::from([
10269            (
10270                "index.html".to_string(),
10271                FragmentList {
10272                    fragments: vec![
10273                        WebUIFragment::raw("<html><head>"),
10274                        structural_fragment("head_start"),
10275                        structural_fragment("head_end"),
10276                        WebUIFragment::raw("</head><body>"),
10277                        structural_fragment("body_start"),
10278                        WebUIFragment::raw("<comp-a"),
10279                        structural_fragment("streaming_root:comp-a"),
10280                        WebUIFragment::raw(">"),
10281                        WebUIFragment::component("comp-a"),
10282                        WebUIFragment::raw("</comp-a>"),
10283                        structural_fragment("body_end"),
10284                        WebUIFragment::raw("</body></html>"),
10285                    ],
10286                },
10287            ),
10288            (
10289                "comp-a".to_string(),
10290                FragmentList {
10291                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
10292                },
10293            ),
10294        ]);
10295        let protocol = Protocol::new(WebUIProtocol::new(fragments));
10296        let mut writer = FlushTestWriter::default();
10297        let result = WebUIHandler::new().render_streaming(
10298            &protocol,
10299            &test_json!({}),
10300            &RenderOptions::new("index.html", "/"),
10301            &mut writer,
10302        );
10303        match result {
10304            Err(HandlerError::StreamingBoundary(err)) => {
10305                assert_eq!(err.signal, "streaming_root:comp-a");
10306                assert!(
10307                    err.reason.contains("outside any <boundary>"),
10308                    "reason: {}",
10309                    err.reason
10310                );
10311            }
10312            other => panic!("expected outside-boundary rejection, got {other:?}"),
10313        }
10314    }
10315
10316    fn parser_route_protocol(with_boundary: bool) -> Protocol {
10317        let mut parser = HtmlParser::with_options(DomStrategy::Light);
10318        parser
10319            .component_registry_mut()
10320            .register_component(ComponentRegistration::new(
10321                "route-page",
10322                "<p>route content</p>",
10323                None,
10324                true,
10325            ))
10326            .expect("register route component");
10327        let route = r#"<route path="/" component="route-page" exact />"#;
10328        let html = if with_boundary {
10329            format!(
10330                "<html><head></head><body>\
10331                 <boundary name=\"route\">{route}</boundary>\
10332                 </body></html>"
10333            )
10334        } else {
10335            format!("<html><head></head><body>{route}</body></html>")
10336        };
10337        parser
10338            .parse("index.html", &html)
10339            .expect("parse route streaming fixture");
10340        let mut document = WebUIProtocol::new(parser.into_fragment_records());
10341        document.components.insert(
10342            "route-page".to_string(),
10343            webui_protocol::ComponentData::default(),
10344        );
10345        Protocol::new(document)
10346    }
10347
10348    #[test]
10349    fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
10350        let protocol = parser_route_protocol(true);
10351        let mut writer = FlushTestWriter::default();
10352        WebUIHandler::new()
10353            .render_streaming(
10354                &protocol,
10355                &test_json!({}),
10356                &RenderOptions::new("index.html", "/"),
10357                &mut writer,
10358            )
10359            .unwrap();
10360
10361        assert!(
10362            writer
10363                .output
10364                .contains("<route-page data-ws><p>route content</p></route-page>"),
10365            "matched route host must be deferred before upgrade: {}",
10366            writer.output
10367        );
10368    }
10369
10370    #[test]
10371    fn streaming_parser_route_host_outside_boundary_is_rejected() {
10372        let protocol = parser_route_protocol(false);
10373        let mut writer = FlushTestWriter::default();
10374        let result = WebUIHandler::new().render_streaming(
10375            &protocol,
10376            &test_json!({}),
10377            &RenderOptions::new("index.html", "/"),
10378            &mut writer,
10379        );
10380
10381        match result {
10382            Err(HandlerError::StreamingBoundary(error)) => {
10383                assert_eq!(error.signal, "streaming_root:route-page");
10384                assert!(error.reason.contains("outside any <boundary>"));
10385            }
10386            other => panic!("expected route boundary rejection, got {other:?}"),
10387        }
10388        assert!(
10389            !writer.output.contains("<route-page>"),
10390            "an unmarked custom-element opening must never be completed"
10391        );
10392    }
10393
10394    #[test]
10395    fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
10396        let entry = vec![
10397            WebUIFragment::raw("<html><head>"),
10398            structural_fragment("head_start"),
10399            structural_fragment("head_end"),
10400            WebUIFragment::raw("</head><body>"),
10401            structural_fragment("body_start"),
10402            structural_fragment("boundary_start:0"),
10403            WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
10404                path: "/account".into(),
10405                fragment_id: "account-shell".into(),
10406                exact: false,
10407                ..Default::default()
10408            }),
10409            structural_fragment("boundary_end:0"),
10410            structural_fragment("body_end"),
10411            WebUIFragment::raw("</body></html>"),
10412        ];
10413        let fragments = HashMap::from([
10414            ("index.html".to_string(), FragmentList { fragments: entry }),
10415            (
10416                "account-shell".to_string(),
10417                FragmentList {
10418                    fragments: vec![WebUIFragment::route_from(
10419                        webui_protocol::WebUiFragmentRoute {
10420                            path: "./details".into(),
10421                            fragment_id: "details-page".into(),
10422                            exact: true,
10423                            pending_component: "details-loading".into(),
10424                            error_component: "details-error".into(),
10425                            ..Default::default()
10426                        },
10427                    )],
10428                },
10429            ),
10430            (
10431                "details-page".to_string(),
10432                FragmentList {
10433                    fragments: vec![WebUIFragment::raw("<p>details</p>")],
10434                },
10435            ),
10436            (
10437                "details-loading".to_string(),
10438                FragmentList {
10439                    fragments: vec![WebUIFragment::raw("<p>loading</p>")],
10440                },
10441            ),
10442            (
10443                "details-error".to_string(),
10444                FragmentList {
10445                    fragments: vec![WebUIFragment::raw("<p>error</p>")],
10446                },
10447            ),
10448        ]);
10449        let mut document = WebUIProtocol::new(fragments);
10450        document.initial_state_strategy = InitialStateStrategy::Components as i32;
10451        for name in [
10452            "account-shell",
10453            "details-page",
10454            "details-loading",
10455            "details-error",
10456        ] {
10457            document.components.insert(
10458                name.to_string(),
10459                webui_protocol::ComponentData {
10460                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
10461                    ..Default::default()
10462                },
10463            );
10464        }
10465        let protocol = Protocol::new(document);
10466        let handler = WebUIHandler::with_plugin(|| {
10467            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10468        });
10469        let mut writer = FlushTestWriter::default();
10470
10471        handler
10472            .render_streaming(
10473                &protocol,
10474                &test_json!({}),
10475                &RenderOptions::new("index.html", "/account/details"),
10476                &mut writer,
10477            )
10478            .unwrap();
10479
10480        let first = &writer.output[..writer.flushes[0]];
10481        assert!(
10482            first.contains(r#""details-loading":"#),
10483            "relative-route pending metadata missing: {first}"
10484        );
10485        assert!(
10486            first.contains(r#""details-error":"#),
10487            "relative-route error metadata missing: {first}"
10488        );
10489    }
10490
10491    #[test]
10492    fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
10493        let entry = vec![
10494            WebUIFragment::raw("<html><head>"),
10495            structural_fragment("head_start"),
10496            structural_fragment("head_end"),
10497            WebUIFragment::raw("</head><body>"),
10498            structural_fragment("body_start"),
10499            structural_fragment("boundary_start:0"),
10500            WebUIFragment::raw("<static-shell"),
10501            structural_fragment("streaming_root:static-shell"),
10502            WebUIFragment::raw(">"),
10503            WebUIFragment::component("static-shell"),
10504            WebUIFragment::raw("</static-shell>"),
10505            WebUIFragment::raw("<route-shell"),
10506            structural_fragment("streaming_root:route-shell"),
10507            WebUIFragment::raw(">"),
10508            WebUIFragment::component("route-shell"),
10509            WebUIFragment::raw("</route-shell>"),
10510            structural_fragment("boundary_end:0"),
10511            structural_fragment("body_end"),
10512            WebUIFragment::raw("</body></html>"),
10513        ];
10514        let fragments = HashMap::from([
10515            ("index.html".to_string(), FragmentList { fragments: entry }),
10516            (
10517                "static-shell".to_string(),
10518                FragmentList {
10519                    fragments: vec![WebUIFragment::if_cond(
10520                        ConditionExpr::identifier("show_hidden"),
10521                        "static-hidden-if",
10522                    )],
10523                },
10524            ),
10525            (
10526                "static-hidden-if".to_string(),
10527                FragmentList {
10528                    fragments: vec![WebUIFragment::component("static-hidden")],
10529                },
10530            ),
10531            (
10532                "static-hidden".to_string(),
10533                FragmentList {
10534                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
10535                },
10536            ),
10537            (
10538                "route-shell".to_string(),
10539                FragmentList {
10540                    fragments: vec![WebUIFragment::route_from(
10541                        webui_protocol::WebUiFragmentRoute {
10542                            path: "/account".into(),
10543                            fragment_id: "route-page".into(),
10544                            exact: true,
10545                            ..Default::default()
10546                        },
10547                    )],
10548                },
10549            ),
10550            (
10551                "route-page".to_string(),
10552                FragmentList {
10553                    fragments: vec![WebUIFragment::raw("<p>route</p>")],
10554                },
10555            ),
10556        ]);
10557        let mut document = WebUIProtocol::new(fragments);
10558        document.set_css_strategy(webui_protocol::CssStrategy::Link);
10559        document.initial_state_strategy = InitialStateStrategy::Components as i32;
10560        for (name, keys) in [
10561            ("static-shell", vec!["show_hidden", "static_count"]),
10562            ("static-hidden", vec!["hidden_count"]),
10563            ("route-shell", vec!["route_shell_count"]),
10564            ("route-page", vec!["route_count"]),
10565        ] {
10566            document.components.insert(
10567                name.to_string(),
10568                webui_protocol::ComponentData {
10569                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
10570                    hydration_mode: StateProjectionMode::Keys as i32,
10571                    hydration_keys: keys.into_iter().map(str::to_string).collect(),
10572                    css_href: format!("/{name}.css"),
10573                    ..Default::default()
10574                },
10575            );
10576        }
10577        let protocol = Protocol::new(document);
10578        assert_eq!(
10579            protocol
10580                .component_reachability()
10581                .is_route_dependent(protocol.component_index()["route-shell"]),
10582            Some(true),
10583            "fixture must exercise request-aware checkpoint reachability"
10584        );
10585        let handler = WebUIHandler::with_plugin(|| {
10586            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10587        });
10588        let mut writer = FlushTestWriter::default();
10589
10590        handler
10591            .render_streaming(
10592                &protocol,
10593                &test_json!({
10594                    "show_hidden": false,
10595                    "static_count": 1,
10596                    "hidden_count": 2,
10597                    "route_shell_count": 3,
10598                    "route_count": 4,
10599                    "unrelated": "private",
10600                }),
10601                &RenderOptions::new("index.html", "/account"),
10602                &mut writer,
10603            )
10604            .unwrap();
10605
10606        let checkpoint = &writer.output[..writer.flushes[0]];
10607        for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
10608            assert!(
10609                checkpoint.contains(&format!(r#""{template}":"#)),
10610                "{template} metadata missing: {checkpoint}"
10611            );
10612        }
10613        for state in [
10614            r#""show_hidden":false"#,
10615            r#""static_count":1"#,
10616            r#""hidden_count":2"#,
10617            r#""route_shell_count":3"#,
10618            r#""route_count":4"#,
10619        ] {
10620            assert!(
10621                checkpoint.contains(state),
10622                "{state} state missing: {checkpoint}"
10623            );
10624        }
10625        assert!(
10626            !checkpoint.contains("unrelated"),
10627            "checkpoint leaked unrelated state: {checkpoint}"
10628        );
10629        assert!(
10630            checkpoint.contains(
10631                r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
10632            ),
10633            "checkpoint metadata lost source order: {checkpoint}"
10634        );
10635    }
10636
10637    #[test]
10638    fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
10639        // Three boundaries: comp-a, comp-b, then comp-a reused. Each checkpoint
10640        // envelope must be locally scoped — templates/state for exactly the
10641        // components rendered since the previous checkpoint, with reused tags
10642        // carrying hydration state but never a duplicate template.
10643        let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
10644        let handler = WebUIHandler::with_plugin(|| {
10645            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10646        });
10647        let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
10648        let mut writer = FlushTestWriter::default();
10649        handler
10650            .render_streaming(
10651                &protocol,
10652                &state,
10653                &RenderOptions::new("index.html", "/"),
10654                &mut writer,
10655            )
10656            .unwrap();
10657
10658        assert_eq!(
10659            writer.flushes.len(),
10660            4,
10661            "three boundary commits plus one terminal"
10662        );
10663        let segment = |index: usize| -> &str {
10664            let start = if index == 0 {
10665                0
10666            } else {
10667                writer.flushes[index - 1]
10668            };
10669            &writer.output[start..writer.flushes[index]]
10670        };
10671        let b0 = segment(0);
10672        let b1 = segment(1);
10673        let b2 = segment(2);
10674
10675        // Boundary 0: only comp-a's template/state; no comp-b artifacts.
10676        assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
10677        assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
10678        assert!(b0.contains(r#"[1,0,0,0,{"inventory":"01""#), "b0: {b0}");
10679        assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
10680        assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
10681
10682        // Boundary 1: only comp-b's template/state; no duplicate comp-a template.
10683        assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
10684        assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
10685        assert!(b1.contains(r#"[1,1,0,1,{"inventory":"02""#), "b1: {b1}");
10686        assert!(
10687            !b1.contains(r#""templates":{"comp-a"#),
10688            "b1 re-sent comp-a: {b1}"
10689        );
10690        assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
10691
10692        // Boundary 2: comp-a reused — state present, template absent (empty delta).
10693        assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
10694        assert!(b2.contains(r#"[1,2,0,2,{"inventory":"""#), "b2: {b2}");
10695        assert!(
10696            !b2.contains(r#""templates""#),
10697            "b2 re-sent a template: {b2}"
10698        );
10699
10700        // Server-only state never leaks into any envelope.
10701        assert!(!writer.output.contains("serverOnly"));
10702    }
10703
10704    #[test]
10705    fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
10706        let entry = vec![
10707            WebUIFragment::raw("<html><head>"),
10708            structural_fragment("head_start"),
10709            structural_fragment("head_end"),
10710            WebUIFragment::raw("</head><body>"),
10711            structural_fragment("body_start"),
10712            structural_fragment("boundary_start:0"),
10713            WebUIFragment::raw("<comp-a"),
10714            structural_fragment("streaming_root:comp-a"),
10715            WebUIFragment::raw(">"),
10716            WebUIFragment::component("comp-a"),
10717            WebUIFragment::raw("</comp-a>"),
10718            structural_fragment("boundary_end:0"),
10719            structural_fragment("boundary_start:1"),
10720            WebUIFragment::raw("<comp-hidden"),
10721            structural_fragment("streaming_root:comp-hidden"),
10722            WebUIFragment::raw(">"),
10723            WebUIFragment::component("comp-hidden"),
10724            WebUIFragment::raw("</comp-hidden>"),
10725            structural_fragment("boundary_end:1"),
10726            structural_fragment("body_end"),
10727            WebUIFragment::raw("</body></html>"),
10728        ];
10729        let fragments = HashMap::from([
10730            ("index.html".to_string(), FragmentList { fragments: entry }),
10731            (
10732                "comp-a".to_string(),
10733                FragmentList {
10734                    fragments: vec![WebUIFragment::if_cond(
10735                        ConditionExpr::identifier("show_hidden"),
10736                        "hidden-if",
10737                    )],
10738                },
10739            ),
10740            (
10741                "hidden-if".to_string(),
10742                FragmentList {
10743                    fragments: vec![WebUIFragment::component("comp-hidden")],
10744                },
10745            ),
10746            (
10747                "comp-hidden".to_string(),
10748                FragmentList {
10749                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
10750                },
10751            ),
10752        ]);
10753        let mut document = WebUIProtocol::new(fragments);
10754        document.initial_state_strategy = InitialStateStrategy::Components as i32;
10755        document.components.insert(
10756            "comp-a".to_string(),
10757            webui_protocol::ComponentData {
10758                template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
10759                hydration_mode: StateProjectionMode::Keys as i32,
10760                hydration_keys: vec!["show_hidden".to_string()],
10761                ..Default::default()
10762            },
10763        );
10764        document.components.insert(
10765            "comp-hidden".to_string(),
10766            webui_protocol::ComponentData {
10767                template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
10768                css: ".hidden{display:block}".to_string(),
10769                hydration_mode: StateProjectionMode::Keys as i32,
10770                hydration_keys: vec!["hidden_count".to_string()],
10771                ..Default::default()
10772            },
10773        );
10774        let protocol = Protocol::new(document);
10775        let handler = WebUIHandler::with_plugin(|| {
10776            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10777        });
10778        let mut writer = FlushTestWriter::default();
10779
10780        handler
10781            .render_streaming(
10782                &protocol,
10783                &test_json!({
10784                    "show_hidden": false,
10785                    "hidden_count": 7,
10786                    "unrelated": "private",
10787                }),
10788                &RenderOptions::new("index.html", "/"),
10789                &mut writer,
10790            )
10791            .unwrap();
10792
10793        let boundary = |index: usize| -> &str {
10794            let start = if index == 0 {
10795                0
10796            } else {
10797                writer.flushes[index - 1]
10798            };
10799            &writer.output[start..writer.flushes[index]]
10800        };
10801        let first = boundary(0);
10802        let second = boundary(1);
10803
10804        assert!(first.contains(r#""comp-a":"#), "first: {first}");
10805        assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
10806        assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
10807        assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
10808        assert!(first.contains(r#""inventory":"01""#), "first: {first}");
10809        assert!(
10810            first.contains(r#"<script type="importmap">"#),
10811            "first: {first}"
10812        );
10813        assert!(
10814            !first.contains("unrelated"),
10815            "first leaked unrelated state: {first}"
10816        );
10817
10818        assert!(
10819            !second.contains(r#""templates""#),
10820            "second re-sent metadata: {second}"
10821        );
10822        assert!(
10823            !second.contains(r#"<script type="importmap">"#),
10824            "second re-sent CSS: {second}"
10825        );
10826        assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
10827        assert!(second.contains(r#""inventory":"02""#), "second: {second}");
10828        assert_eq!(
10829            writer
10830                .output
10831                .matches(r#"<script type="importmap">"#)
10832                .count(),
10833            1
10834        );
10835    }
10836
10837    #[test]
10838    fn streaming_flush_count_is_boundaries_plus_one() {
10839        for &count in &[1usize, 3, 10, 100] {
10840            let hosts: Vec<&str> = (0..count)
10841                .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
10842                .collect();
10843            let protocol = disjoint_streaming_protocol(&hosts);
10844            let handler = WebUIHandler::with_plugin(|| {
10845                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10846            });
10847            let mut writer = FlushTestWriter::default();
10848            handler
10849                .render_streaming(
10850                    &protocol,
10851                    &test_json!({ "a_count": 1, "b_count": 2 }),
10852                    &RenderOptions::new("index.html", "/"),
10853                    &mut writer,
10854                )
10855                .unwrap();
10856            assert_eq!(
10857                writer.flushes.len(),
10858                count + 1,
10859                "expected boundaries+1 flushes for {count} boundaries"
10860            );
10861        }
10862    }
10863
10864    #[test]
10865    fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
10866        let mut protocol = WebUIProtocol::new(HashMap::new());
10867        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
10868        protocol.components.insert(
10869            "comp-a".to_string(),
10870            webui_protocol::ComponentData {
10871                hydration_mode: StateProjectionMode::Keys as i32,
10872                hydration_keys: vec!["a".to_string(), "shared".to_string()],
10873                ..Default::default()
10874            },
10875        );
10876        protocol.components.insert(
10877            "comp-b".to_string(),
10878            webui_protocol::ComponentData {
10879                hydration_mode: StateProjectionMode::Keys as i32,
10880                hydration_keys: vec!["b".to_string(), "shared".to_string()],
10881                ..Default::default()
10882            },
10883        );
10884
10885        let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
10886        assert!(!collect_hydration_state_into(
10887            &protocol,
10888            ["comp-a"],
10889            &mut scratch,
10890        ));
10891        assert_eq!(scratch, ["a", "shared"]);
10892        let pointer = scratch.as_ptr();
10893        let capacity = scratch.capacity();
10894
10895        assert!(!collect_hydration_state_into(
10896            &protocol,
10897            ["comp-b"],
10898            &mut scratch,
10899        ));
10900        assert_eq!(scratch, ["b", "shared"]);
10901        assert_eq!(scratch.as_ptr(), pointer);
10902        assert_eq!(scratch.capacity(), capacity);
10903    }
10904}