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
221/// Reserved top-level state key carrying host-supplied boundary HTML.
222///
223/// The leading `$` marks this key as reserved host metadata rather than
224/// ordinary application state.
225///
226/// Recognized members, each an optional string:
227///
228/// | Member | Emitted at |
229/// | --- | --- |
230/// | `headEnd` | immediately before `</head>` |
231/// | `bodyStart` | immediately after `<body>` |
232/// | `bodyEnd` | immediately before `</body>` |
233///
234/// The key is always honored — it is part of the render state the host
235/// already supplies — and is stripped from the client hydration payload.
236pub const STATE_INJECT_KEY: &str = "$webui";
237
238/// Host-supplied boundary HTML resolved once per render from
239/// [`STATE_INJECT_KEY`].
240///
241/// Resolution happens when the render context is built, so each structural
242/// hook costs one `Option` check instead of a state map lookup. Every field
243/// borrows from the caller's state value — no clone, no allocation.
244#[derive(Clone, Copy, Default)]
245pub(crate) struct StateInject<'state> {
246    pub(crate) head_end: Option<&'state str>,
247    pub(crate) body_start: Option<&'state str>,
248    pub(crate) body_end: Option<&'state str>,
249}
250
251impl<'state> StateInject<'state> {
252    /// Resolve the reserved namespace from a render state value.
253    ///
254    /// Returns the empty set when the key is absent, or when any member is
255    /// missing, null, empty, or not a string. Malformed input is inert rather than an error: the reserved
256    /// key is an optional side channel, and a render must not fail because a
257    /// host wrote the wrong shape into it.
258    pub(crate) fn resolve(state: &'state Value) -> Self {
259        let Some(Value::Object(map)) = state.get(STATE_INJECT_KEY) else {
260            return Self::default();
261        };
262        let field = |name: &str| {
263            map.get(name)
264                .and_then(Value::as_str)
265                .filter(|html| !html.is_empty())
266        };
267        Self {
268            head_end: field("headEnd"),
269            body_start: field("bodyStart"),
270            body_end: field("bodyEnd"),
271        }
272    }
273}
274
275impl<'a> RenderOptions<'a> {
276    /// Create render options for the given entry fragment and request path.
277    #[must_use]
278    pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
279        Self {
280            entry_id,
281            request_path,
282            nonce: None,
283            head_inject: None,
284            body_inject: None,
285        }
286    }
287
288    /// Set the CSP nonce for inline scripts. Pass an empty string to
289    /// disable (`None` semantics) — empty `<meta name="webui-nonce"
290    /// content="">` would be browser-ignored noise.
291    #[must_use]
292    pub fn with_nonce(mut self, nonce: &'a str) -> Self {
293        self.nonce = if nonce.is_empty() { None } else { Some(nonce) };
294        self
295    }
296
297    /// Set HTML to emit immediately before `</head>`.
298    /// Pass an empty string to disable (`None` semantics).
299    ///
300    /// # Safety (XSS warning)
301    ///
302    /// The provided HTML is written verbatim — **no HTML escaping is
303    /// performed**. Callers MUST ensure the content is fully trusted
304    /// (typically a `&'static str` or build-time-derived bytes such as
305    /// dev livereload script, image preload `<link>` tags, or A/B test
306    /// markers). Passing user-controlled or attacker-influenced content
307    /// here is a direct cross-site scripting vulnerability. If your
308    /// caller path may include untrusted data, escape with the host's
309    /// HTML escaper (e.g. [`webui_handler::encode_safe`](crate::encode_safe))
310    /// **before** calling this builder.
311    #[must_use]
312    pub fn with_head_inject(mut self, html: &'a str) -> Self {
313        self.head_inject = if html.is_empty() { None } else { Some(html) };
314        self
315    }
316
317    /// Set HTML to emit immediately before `</body>`.
318    /// Pass an empty string to disable (`None` semantics).
319    ///
320    /// # Safety (XSS warning)
321    ///
322    /// Same contract as [`with_head_inject`](Self::with_head_inject):
323    /// the HTML is written verbatim with **no escaping**, so callers
324    /// MUST ensure the content is fully trusted. Untrusted content is
325    /// a direct XSS vector.
326    #[must_use]
327    pub fn with_body_inject(mut self, html: &'a str) -> Self {
328        self.body_inject = if html.is_empty() { None } else { Some(html) };
329        self
330    }
331}
332
333/// The main WebUI handler that processes protocols and renders them.
334///
335/// The handler is stateless: plugin instances are created per-render from
336/// the stored factory function, allowing concurrent renders with `&self`.
337pub struct WebUIHandler {
338    plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
339}
340
341/// Context object for processing WebUI fragments
342pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> {
343    pub(crate) protocol: &'protocol WebUIProtocol,
344    pub(crate) state: &'state Value,
345    pub(crate) writer: &'output mut dyn ResponseWriter,
346    pub(crate) local_vars: HashMap<String, Value>,
347    /// Accumulates component attribute values between attrStart and the component fragment.
348    pub(crate) component_attrs: HashMap<String, Value>,
349    /// URL path for server-side route matching. Borrowed from
350    /// `RenderOptions<'a>::request_path` — zero-copy.
351    pub(crate) request_path: &'protocol str,
352    /// Base path for resolving relative route paths (`./`).
353    /// Updated as the handler descends into nested matched routes.
354    /// `Cow` keeps the initial `"/"` literal zero-copy; nested-route
355    /// descent owns the recomputed path.
356    pub(crate) route_base: Cow<'protocol, str>,
357    /// Component names visited during rendering (for selective f-template emission
358    /// and CSS module dedup — only the first render of each component emits
359    /// its `<script type="importmap">` data-URI tag).
360    pub(crate) rendered_components: HashSet<String>,
361    /// Per-render plugin instance created from the handler's factory.
362    pub(crate) plugin: Option<Box<dyn HandlerPlugin>>,
363    /// Current position in the route tree for outlet-based rendering.
364    /// Contains the children of the currently matched route fragment.
365    pub(crate) route_children: Vec<webui_protocol::WebUiFragmentRoute>,
366    /// Entry fragment ID — used to compute the initial inventory at head_end.
367    /// Borrowed from `RenderOptions<'a>::entry_id` — zero-copy.
368    pub(crate) entry_id: &'protocol str,
369    /// CSP nonce for inline `<script>` tags (None = no nonce attribute).
370    /// Borrowed from `RenderOptions<'a>::nonce` — zero-copy.
371    pub(crate) nonce: Option<&'protocol str>,
372    /// Component-name → bit-position map built once when the runtime
373    /// [`Protocol`] is created and shared by every render.
374    pub(crate) component_index: &'protocol HashMap<String, u32>,
375    /// HTML emitted at the structural `head_end` boundary (before
376    /// `</head>`), after the built-in nonce/CSS-preload emissions.
377    /// Zero-copy borrow of the caller's `RenderOptions<'a>::head_inject`
378    /// (no per-render clone — saves an allocation when the host passes
379    /// a `&'static str` such as a dev livereload script).
380    pub(crate) head_inject: Option<&'protocol str>,
381    /// HTML emitted at the structural `body_end` boundary (before
382    /// `</body>`), after the built-in template metadata emissions.
383    /// Same zero-copy borrow as [`head_inject`](Self::head_inject).
384    pub(crate) body_inject: Option<&'protocol str>,
385    /// Host HTML resolved once per render from the reserved
386    /// [`STATE_INJECT_KEY`] state namespace. Emitted after the built-in
387    /// emissions at each structural boundary, and after the corresponding
388    /// `RenderOptions` inject at `head_end` and `body_end`.
389    pub(crate) state_inject: StateInject<'state>,
390    /// Tracks whether the `head_end` hook has already fired in this
391    /// render. Defends against malformed protocols that emit the
392    /// signal more than once (e.g., a template with multiple `<head>`
393    /// tags) — without this, host-supplied `head_inject` HTML, CSS
394    /// preload `<link>` tags, and the CSP `<meta>` nonce would be
395    /// duplicated, which can be a CSP-bypass / cache-bloat vector.
396    pub(crate) head_end_emitted: bool,
397    /// Tracks whether the `body_start` hook has already fired in this
398    /// render. Defends against malformed protocols emitting the signal
399    /// twice — without this, state-supplied `bodyStart` HTML would be
400    /// duplicated.
401    pub(crate) body_start_emitted: bool,
402    /// Tracks whether the `body_end` hook has already fired in this
403    /// render. Defends against malformed protocols emitting the
404    /// signal twice — without this, hydration `<script>` blocks and
405    /// host-supplied `body_inject` would be duplicated.
406    pub(crate) body_end_emitted: bool,
407    /// Immutable authored route patterns compiled when [`Protocol`] is loaded.
408    pub(crate) route_index: &'protocol CompiledRouteIndex,
409    /// Counter for `data-ri` attributes on matched route elements.
410    /// Incremented each time a matched route is rendered, allowing O(1) element
411    /// binding on the client side instead of DOM-walking.
412    pub(crate) route_chain_index: usize,
413    /// Present only for the opt-in progressive streaming render path.
414    pub(crate) streaming: Option<&'output mut StreamingRenderState<'protocol>>,
415    /// Reusable JSON serialization scratch buffer, owned by the render context.
416    /// The bootstrap/field/template helpers borrow it so each render reuses one
417    /// buffer across every serialized field and every streaming checkpoint
418    /// instead of allocating per value. It grows lazily on first serialization
419    /// and is dropped with the context at request end — no per-thread
420    /// high-water buffer is retained between requests.
421    pub(crate) json_scratch: Vec<u8>,
422    /// Small request-local pool of cleared scope maps reused across sibling
423    /// component roots. `process_component` recycles each finished component's
424    /// local/attr map here instead of dropping it, so a sibling reuses the
425    /// bucket capacity rather than reallocating a fresh `HashMap`. Bounded
426    /// ([`SCOPE_POOL_CAP`]) and dropped with the context at request end.
427    pub(crate) scope_pool: Vec<HashMap<String, Value>>,
428}
429
430/// Compiler-owned signal namespace. The leading `}}}` cannot be produced by
431/// authored double- or triple-brace expressions because it closes the binding.
432pub(crate) const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
433
434pub(crate) fn structural_signal_value(
435    signal: &webui_protocol::WebUIFragmentSignal,
436) -> Option<&str> {
437    if !signal.raw {
438        return None;
439    }
440    signal.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
441}
442
443/// Maximum scope maps retained in the request-local pool. Small: sibling
444/// component roots rarely nest deeply, so the cap keeps retained capacity bounded.
445const SCOPE_POOL_CAP: usize = 8;
446
447/// Take a cleared scope map from the pool, or a fresh empty one when the pool is
448/// empty. A fresh `HashMap` does not allocate until its first insert.
449fn take_scope_map(pool: &mut Vec<HashMap<String, Value>>) -> HashMap<String, Value> {
450    pool.pop().unwrap_or_default()
451}
452
453/// Return a spent scope map to the pool, clearing it but retaining its bucket
454/// capacity for a sibling root to reuse. Drops the map once the pool is full so
455/// retained memory stays bounded.
456fn recycle_scope_map(pool: &mut Vec<HashMap<String, Value>>, mut map: HashMap<String, Value>) {
457    if pool.len() < SCOPE_POOL_CAP {
458        map.clear();
459        pool.push(map);
460    }
461}
462
463pub(crate) struct WebUiBootstrap<'a> {
464    pub(crate) state: &'a Value,
465    pub(crate) state_selection: StateSelection<'a>,
466    pub(crate) chain: &'a [Value],
467    pub(crate) inventory: &'a str,
468    pub(crate) nonce: Option<&'a str>,
469    pub(crate) css_hrefs: &'a [&'a str],
470    pub(crate) style_specs: &'a [&'a str],
471    pub(crate) templates: &'a [WebUiTemplatePayload<'a>],
472}
473
474/// Get the component attribute name, stripping `:` prefix and converting to camelCase.
475///
476/// Uses `webui_protocol::attrs::attribute_to_camel` which handles irregular
477/// attributes (multi-word ARIA and global HTML attributes like `readonly`,
478/// `tabindex`) via the shared lookup table.
479fn component_attr_name(name: &str) -> String {
480    let stripped = name.strip_prefix(':').unwrap_or(name);
481    webui_protocol::attrs::attribute_to_camel(stripped)
482}
483
484/// Write a usize as decimal digits directly to the writer, avoiding `format!` allocation.
485pub(crate) fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
486    if n == 0 {
487        return writer.write("0");
488    }
489    // Max digits for a 64-bit usize is 20.
490    let mut buf = [0u8; 20];
491    let mut pos = buf.len();
492    while n > 0 {
493        pos -= 1;
494        // n % 10 is always in 0..=9, fits in u8 without truncation.
495        #[allow(clippy::cast_possible_truncation)]
496        let digit = (n % 10) as u8;
497        buf[pos] = b'0' + digit;
498        n /= 10;
499    }
500    // Digits are always valid ASCII/UTF-8.
501    match std::str::from_utf8(&buf[pos..]) {
502        Ok(s) => writer.write(s),
503        Err(_) => writer.write("0"),
504    }
505}
506
507pub(crate) fn write_script_safe_json<T>(
508    writer: &mut dyn ResponseWriter,
509    scratch: &mut Vec<u8>,
510    value: &T,
511) -> Result<()>
512where
513    T: Serialize + ?Sized,
514{
515    // Serialize into the caller's request-local `scratch`. The streaming path
516    // emits one bootstrap envelope per committed boundary and the ordinary
517    // body_end bootstrap serializes several fields, so reusing one buffer across
518    // the render avoids a fresh allocation per value. The buffer grows lazily on
519    // first use (no allocation until serialization needs it) and is dropped with
520    // the render context — capacity is reused within a request but never
521    // retained across requests.
522    scratch.clear();
523    serde_json::to_writer(&mut *scratch, value)
524        .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
525    let json = std::str::from_utf8(scratch)
526        .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
527    write_script_safe_json_str(writer, json)
528}
529
530fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
531    let mut start = 0;
532    while start < json.len() {
533        let rest = &json[start..];
534        let Some(offset) = rest.find("</") else {
535            writer.write(rest)?;
536            return Ok(());
537        };
538
539        if offset > 0 {
540            writer.write(&rest[..offset])?;
541        }
542        writer.write("<\\/")?;
543        start += offset + 2;
544    }
545    Ok(())
546}
547
548fn write_json_field_name(
549    writer: &mut dyn ResponseWriter,
550    wrote_field: &mut bool,
551    name: &str,
552) -> Result<()> {
553    if *wrote_field {
554        writer.write(",")?;
555    }
556    *wrote_field = true;
557    writer.write("\"")?;
558    writer.write(name)?;
559    writer.write("\":")
560}
561
562fn write_json_field<T>(
563    writer: &mut dyn ResponseWriter,
564    scratch: &mut Vec<u8>,
565    wrote_field: &mut bool,
566    name: &str,
567    value: &T,
568) -> Result<()>
569where
570    T: Serialize + ?Sized,
571{
572    write_json_field_name(writer, wrote_field, name)?;
573    write_script_safe_json(writer, scratch, value)
574}
575
576/// Serialize wrapper that projects an SSR state object down to only the
577/// keys present in the build-time hydration allowlist.
578///
579/// This is the runtime half of the projected-hydration design: instead of
580/// serializing the entire application state (potentially megabytes) on every
581/// full-HTML render, only the fields a component actually hydrates are
582/// emitted. The request allowlist conservatively includes every reachable
583/// component's hydration keys so no field a component needs is dropped.
584///
585/// Projection is a payload boundary, not a secrecy boundary. Any key selected
586/// by compiled client metadata is browser-facing, so hosts must never place
587/// secrets in browser render state.
588///
589/// `keys` MUST be sorted and deduplicated. Projection iterates whichever side
590/// is smaller: hydration keys with direct map lookup for wide states, or state
591/// entries with binary-search membership for compact states. Non-object states
592/// carry nothing hydratable and serialize as an empty object.
593struct ProjectedState<'a> {
594    value: &'a Value,
595    keys: &'a [&'a str],
596}
597
598impl Serialize for ProjectedState<'_> {
599    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
600    where
601        S: serde::Serializer,
602    {
603        let Value::Object(map) = self.value else {
604            return serializer.serialize_map(Some(0))?.end();
605        };
606
607        let mut out = serializer.serialize_map(None)?;
608        if self.keys.len() < map.len() {
609            let mut previous = None;
610            for key in self.keys {
611                if *key == STATE_INJECT_KEY {
612                    continue;
613                }
614                if previous == Some(*key) {
615                    continue;
616                }
617                previous = Some(*key);
618                if let Some(value) = map.get(*key) {
619                    out.serialize_entry(key, value)?;
620                }
621            }
622        } else {
623            for (key, value) in map {
624                if key == STATE_INJECT_KEY {
625                    continue;
626                }
627                if self
628                    .keys
629                    .binary_search_by(|candidate| candidate.cmp(&key.as_str()))
630                    .is_ok()
631                {
632                    out.serialize_entry(key, value)?;
633                }
634            }
635        }
636        out.end()
637    }
638}
639
640/// Write the SSR `state` into the bootstrap block according to the protocol's
641/// build-time selection and escape it for safe embedding in a `<script>`.
642///
643/// [`ProjectedState`] serializes only the allowlisted keys, so for the typical
644/// payload — a large state with a small hydratable surface — serde ever only
645/// touches the projected subset. Serialization reuses the proven
646/// [`write_script_safe_json`] path (serde's fast `Vec<u8>` target plus a single
647/// SIMD-accelerated `</` escape pass), which matches the pre-projection cost
648/// when every key is hydratable and collapses to a few bytes when it is not.
649/// Buffering the projected bytes and escaping once is measurably faster than
650/// streaming through a per-token `io::Write` adapter, and the projected buffer
651/// is tiny in the common case.
652/// Serialize a state object with the reserved [`STATE_INJECT_KEY`] entry
653/// omitted.
654///
655/// The reserved key carries host-supplied boundary HTML, not application
656/// state, so it must never reach the client hydration payload — shipping it
657/// would both duplicate the markup as a JSON string and expose the host's
658/// inject channel to client code. Filtering happens during serialization so
659/// the state tree is never cloned.
660struct StateWithoutReservedKey<'a> {
661    value: &'a Value,
662}
663
664impl Serialize for StateWithoutReservedKey<'_> {
665    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
666    where
667        S: serde::Serializer,
668    {
669        let Value::Object(map) = self.value else {
670            return self.value.serialize(serializer);
671        };
672        let mut out = serializer.serialize_map(Some(map.len().saturating_sub(1)))?;
673        for (key, value) in map {
674            if key == STATE_INJECT_KEY {
675                continue;
676            }
677            out.serialize_entry(key, value)?;
678        }
679        out.end()
680    }
681}
682
683/// Write the complete state, stripping the reserved inject key when present.
684///
685/// The membership test is a single map lookup and the common case — no
686/// reserved key — takes the original zero-overhead path unchanged.
687fn write_full_state(
688    writer: &mut dyn ResponseWriter,
689    scratch: &mut Vec<u8>,
690    state: &Value,
691) -> Result<()> {
692    if matches!(state, Value::Object(map) if map.contains_key(STATE_INJECT_KEY)) {
693        return write_script_safe_json(writer, scratch, &StateWithoutReservedKey { value: state });
694    }
695    write_script_safe_json(writer, scratch, state)
696}
697
698pub(crate) fn write_selected_state(
699    writer: &mut dyn ResponseWriter,
700    scratch: &mut Vec<u8>,
701    state: &Value,
702    selection: &StateSelection<'_>,
703) -> Result<()> {
704    let keys = match selection {
705        StateSelection::Full => return write_full_state(writer, scratch, state),
706        StateSelection::Keys(keys) => keys.as_slice(),
707        StateSelection::BorrowedKeys(keys) => *keys,
708    };
709    if keys.is_empty() {
710        return writer.write("{}");
711    }
712
713    // Projection membership may use binary search, so a mis-sorted key set
714    // would silently drop hydration keys. The key allowlist is produced sorted +
715    // deduped at build time; this guard makes hand-built protocols that violate
716    // the invariant fail loudly in tests at zero release cost.
717    debug_assert!(
718        keys.windows(2).all(|pair| pair[0] <= pair[1]),
719        "hydration keys must be sorted for binary-search projection"
720    );
721    if let Value::Object(map) = state {
722        let selects_entire_map =
723            keys.len() == map.len() && keys.iter().copied().eq(map.keys().map(String::as_str));
724        if selects_entire_map {
725            return write_full_state(writer, scratch, state);
726        }
727    }
728    write_script_safe_json(writer, scratch, &ProjectedState { value: state, keys })
729}
730
731// Covers the common route surface without trusting protocol-derived counts for
732// an eager allocation; larger key sets grow only as actual keys are visited.
733pub(crate) const INITIAL_KEY_CAPACITY: usize = 16;
734
735/// Request-scoped state selection derived from reachable component metadata.
736pub(crate) enum StateSelection<'a> {
737    /// Preserve the complete state value.
738    Full,
739    /// Project an object to a sorted, deduplicated key allowlist.
740    Keys(Vec<&'a str>),
741    /// Project using request-local scratch owned by the streaming render.
742    BorrowedKeys(&'a [&'a str]),
743}
744
745#[derive(Clone, Copy)]
746enum ComponentStateSurface {
747    Hydration,
748    Navigation,
749}
750
751/// Select initial state for the components reachable on this request path.
752///
753/// Non-WebUI protocols preserve full state without walking component surfaces.
754/// WebUI protocols project exact surfaces, while any unknown surface restores
755/// the full state for correctness.
756pub(crate) fn collect_hydration_state<'a, 'b>(
757    protocol: &'a WebUIProtocol,
758    components: impl IntoIterator<Item = &'b str>,
759) -> StateSelection<'a> {
760    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
761        return StateSelection::Full;
762    }
763    collect_component_state(protocol, components, ComponentStateSurface::Hydration)
764}
765
766pub(crate) fn collect_hydration_state_into<'a, 'b>(
767    protocol: &'a WebUIProtocol,
768    components: impl IntoIterator<Item = &'b str>,
769    keys: &mut Vec<&'a str>,
770) -> bool {
771    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
772        keys.clear();
773        return true;
774    }
775    collect_component_state_into(protocol, components, ComponentStateSurface::Hydration, keys)
776}
777
778/// Select state for client-created components reachable during navigation.
779pub(crate) fn collect_navigation_state<'a, 'b>(
780    protocol: &'a WebUIProtocol,
781    components: impl IntoIterator<Item = &'b str>,
782) -> StateSelection<'a> {
783    collect_component_state(protocol, components, ComponentStateSurface::Navigation)
784}
785
786fn collect_component_state<'a, 'b>(
787    protocol: &'a WebUIProtocol,
788    components: impl IntoIterator<Item = &'b str>,
789    surface: ComponentStateSurface,
790) -> StateSelection<'a> {
791    let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
792    if collect_component_state_into(protocol, components, surface, &mut keys) {
793        StateSelection::Full
794    } else {
795        StateSelection::Keys(keys)
796    }
797}
798
799/// Fill a reusable state-key allowlist. Returns `true` when correctness
800/// requires sending full state instead of the collected keys.
801fn collect_component_state_into<'a, 'b>(
802    protocol: &'a WebUIProtocol,
803    components: impl IntoIterator<Item = &'b str>,
804    surface: ComponentStateSurface,
805    keys: &mut Vec<&'a str>,
806) -> bool {
807    keys.clear();
808    for name in components {
809        let Some(component) = protocol.components.get(name) else {
810            return true;
811        };
812        let (mode, component_keys) = match surface {
813            ComponentStateSurface::Hydration => {
814                (component.hydration_mode, &component.hydration_keys)
815            }
816            ComponentStateSurface::Navigation => (
817                match component.navigation_mode {
818                    Some(mode) => mode,
819                    None if !component.navigation_keys.is_empty() => {
820                        StateProjectionMode::Keys as i32
821                    }
822                    None => return true,
823                },
824                &component.navigation_keys,
825            ),
826        };
827        if mode == StateProjectionMode::All as i32 {
828            return true;
829        }
830        if mode == StateProjectionMode::Keys as i32
831            || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
832        {
833            keys.extend(component_keys.iter().map(String::as_str));
834        } else if mode != StateProjectionMode::None as i32 {
835            return true;
836        }
837    }
838    keys.sort_unstable();
839    keys.dedup();
840    false
841}
842
843pub(crate) fn write_webui_bootstrap(
844    writer: &mut dyn ResponseWriter,
845    scratch: &mut Vec<u8>,
846    bootstrap: WebUiBootstrap<'_>,
847) -> Result<()> {
848    let mut wrote_field = false;
849
850    writer.write("{")?;
851    if !bootstrap.chain.is_empty() {
852        write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?;
853    }
854    if !bootstrap.css_hrefs.is_empty() {
855        write_json_field(
856            writer,
857            scratch,
858            &mut wrote_field,
859            "css",
860            bootstrap.css_hrefs,
861        )?;
862    }
863    write_json_field(
864        writer,
865        scratch,
866        &mut wrote_field,
867        "inventory",
868        bootstrap.inventory,
869    )?;
870    if let Some(nonce) = bootstrap.nonce {
871        write_json_field(writer, scratch, &mut wrote_field, "nonce", nonce)?;
872    }
873    write_json_field_name(writer, &mut wrote_field, "state")?;
874    write_selected_state(writer, scratch, bootstrap.state, &bootstrap.state_selection)?;
875    if !bootstrap.style_specs.is_empty() {
876        write_json_field(
877            writer,
878            scratch,
879            &mut wrote_field,
880            "styles",
881            bootstrap.style_specs,
882        )?;
883    }
884    if bootstrap
885        .templates
886        .iter()
887        .any(|template| !template.template_json.is_empty())
888    {
889        write_json_field_name(writer, &mut wrote_field, "templates")?;
890        write_webui_template_json_map(writer, scratch, bootstrap.templates)?;
891    }
892    writer.write("}")
893}
894
895fn write_webui_data_block(
896    writer: &mut dyn ResponseWriter,
897    scratch: &mut Vec<u8>,
898    bootstrap: WebUiBootstrap<'_>,
899) -> Result<()> {
900    writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
901    if let Some(nonce) = bootstrap.nonce {
902        writer.write(" nonce=\"")?;
903        writer.write(nonce)?;
904        writer.write("\"")?;
905    }
906    writer.write(">")?;
907    write_webui_bootstrap(writer, scratch, bootstrap)?;
908    writer.write("</script>\n")
909}
910
911fn write_webui_template_json_map(
912    writer: &mut dyn ResponseWriter,
913    scratch: &mut Vec<u8>,
914    templates: &[WebUiTemplatePayload<'_>],
915) -> Result<()> {
916    writer.write("{")?;
917    let mut wrote = false;
918    for template in templates {
919        if template.template_json.is_empty() {
920            continue;
921        }
922        if wrote {
923            writer.write(",")?;
924        }
925        wrote = true;
926        write_script_safe_json(writer, scratch, template.tag_name)?;
927        writer.write(":")?;
928        write_script_safe_json_str(writer, template.template_json)?;
929    }
930    writer.write("}")
931}
932
933fn resolve_value_from_sources<'ctx, 'state>(
934    path: &str,
935    local_vars: &'ctx HashMap<String, Value>,
936    state: &'state Value,
937) -> Option<Cow<'ctx, Value>>
938where
939    'state: 'ctx,
940{
941    if let Some(first_part) = path.split('.').next() {
942        if let Some(local_value) = local_vars.get(first_part) {
943            if first_part.len() == path.len() {
944                return Some(Cow::Borrowed(local_value));
945            }
946            let remaining = &path[first_part.len() + 1..];
947            if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
948                return Some(value);
949            }
950        }
951    }
952
953    find_value_by_dotted_path_ref(path, state)
954}
955
956impl WebUIHandler {
957    /// Create a new WebUI handler with no plugin.
958    pub fn new() -> Self {
959        Self {
960            plugin_factory: None,
961        }
962    }
963
964    /// Create a new WebUI handler with a plugin factory.
965    ///
966    /// Each render call creates a fresh plugin instance from the factory,
967    /// enabling concurrent renders with `&self`.
968    pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
969        Self {
970            plugin_factory: Some(factory),
971        }
972    }
973
974    #[cfg(test)]
975    fn handle(
976        &self,
977        document: &WebUIProtocol,
978        state: &Value,
979        options: &RenderOptions<'_>,
980        writer: &mut dyn ResponseWriter,
981    ) -> Result<()> {
982        let protocol = Protocol::new(document.clone());
983        self.render(&protocol, state, options, writer)
984    }
985
986    /// Process a fragment by its ID.
987    ///
988    /// The `context` parameter contains scope-local variables that are accessible during rendering,
989    /// such as loop iteration variables. This is separate from the global `state`.
990    fn process_fragment_id<'data>(
991        &self,
992        fragment_id: &str,
993        context: &mut WebUIProcessContext<'data, '_, '_>,
994    ) -> Result<()> {
995        if let Some(fragment_list) = context.protocol.fragments.get(fragment_id) {
996            self.process_fragment(&fragment_list.fragments, context)
997        } else {
998            Err(HandlerError::MissingFragment(fragment_id.to_string()))
999        }
1000    }
1001
1002    /// Process a vector of fragments.
1003    ///
1004    /// The `context` maintains scope-specific variables that can be accessed by fragments
1005    /// during rendering, while `state` contains the global application state.
1006    fn process_fragment<'data>(
1007        &self,
1008        fragments: &'data [WebUIFragment],
1009        context: &mut WebUIProcessContext<'data, '_, '_>,
1010    ) -> Result<()> {
1011        // Pre-scan: find the best matching route among sibling routes by specificity.
1012        // This ensures `/contacts/add` (2 literals) beats `/contacts/:id` (1 literal).
1013        // Resolves relative paths (`./`) using the current route_base.
1014        let best_route = route_renderer::find_best_route_match(
1015            fragments,
1016            context.request_path,
1017            &context.route_base,
1018            context.route_index,
1019        );
1020        self.process_fragment_range(fragments, 0..fragments.len(), &best_route, context)
1021    }
1022
1023    fn process_fragment_range<'data>(
1024        &self,
1025        fragments: &'data [WebUIFragment],
1026        range: std::ops::Range<usize>,
1027        best_route: &Option<(String, route_matcher::RouteMatch)>,
1028        context: &mut WebUIProcessContext<'data, '_, '_>,
1029    ) -> Result<()> {
1030        let Some(selected) = fragments.get(range.clone()) else {
1031            return Err(invalid_fragment_range_error(&range, fragments.len()));
1032        };
1033        for (offset, item) in selected.iter().enumerate() {
1034            let index = range.start + offset;
1035            if context.streaming.is_some() {
1036                validate_pending_streaming_root(item, context)?;
1037                validate_streaming_root_opening(&fragments[..index], item)?;
1038            }
1039            match item.fragment.as_ref() {
1040                Some(Fragment::Raw(raw)) => {
1041                    context.writer.write(&raw.value)?;
1042                }
1043                Some(Fragment::Component(component)) => {
1044                    self.process_component(
1045                        component,
1046                        ComponentHostOrigin::ParserProduced,
1047                        context,
1048                    )?;
1049                }
1050                Some(Fragment::ForLoop(for_loop)) => {
1051                    self.process_for_loop(for_loop, context)?;
1052                }
1053                Some(Fragment::Signal(signal)) => {
1054                    self.process_signal(signal, context)?;
1055                }
1056                Some(Fragment::IfCond(if_cond)) => {
1057                    self.process_if(if_cond, context)?;
1058                }
1059                Some(Fragment::Attribute(attr)) => {
1060                    self.process_attribute(attr, context)?;
1061                }
1062                Some(Fragment::Plugin(plugin_frag)) => {
1063                    if let Some(p) = &mut context.plugin {
1064                        p.on_element_data(&plugin_frag.data, context.writer)?;
1065                    }
1066                }
1067                Some(Fragment::Route(route_frag)) => {
1068                    self.process_route(route_frag, best_route, context)?;
1069                }
1070                Some(Fragment::Outlet(_)) => {
1071                    self.process_outlet(context)?;
1072                }
1073                None => {}
1074            }
1075        }
1076        ensure_no_pending_streaming_root(context, "the end of the containing fragment")
1077    }
1078
1079    /// Process an `<outlet />` directive.
1080    ///
1081    /// Matches children from the currently active route's `children` field
1082    /// against the request path, renders the matched child `<webui-route>`
1083    /// elements directly at this position (no wrapper element).
1084    fn process_outlet(&self, context: &mut WebUIProcessContext) -> Result<()> {
1085        let mut children = std::mem::take(&mut context.route_children);
1086        if children.is_empty() {
1087            return Ok(());
1088        }
1089
1090        // Find the best matching child route
1091        let request_segments = route_matcher::split_request_path(context.request_path);
1092        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
1093        for (idx, child) in children.iter().enumerate() {
1094            if let Some(m) = route_matcher::match_route_indexed_with_segments(
1095                context.route_index,
1096                &child.path,
1097                &context.route_base,
1098                &request_segments,
1099                child.exact,
1100            ) {
1101                let is_better = best
1102                    .as_ref()
1103                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
1104                if is_better {
1105                    best = Some((idx, m));
1106                }
1107            }
1108        }
1109
1110        // Extract grandchildren from the matched child to avoid cloning.
1111        // We swap out the children vec so we can move it into context without
1112        // cloning, then swap an empty vec back for the sibling rendering pass.
1113        let grandchildren = if let Some((idx, _)) = &best {
1114            std::mem::take(&mut children[*idx].children)
1115        } else {
1116            Vec::new()
1117        };
1118
1119        if let Some((idx, ref rm)) = best {
1120            let matched_child = &children[idx];
1121            let comp = &matched_child.fragment_id;
1122
1123            if !comp.is_empty() {
1124                let saved_route_base = context.route_base.clone();
1125                let saved_route_children = std::mem::take(&mut context.route_children);
1126
1127                if rm.consumed_segments > 0 {
1128                    context.route_base = Cow::Owned(route_matcher::compute_route_base(
1129                        context.request_path,
1130                        rm.consumed_segments,
1131                    ));
1132                }
1133
1134                context.route_children = grandchildren;
1135
1136                // Emit matched <webui-route>
1137                context.writer.write("<webui-route")?;
1138                if !matched_child.path.is_empty() {
1139                    context.writer.write(" path=\"")?;
1140                    context.writer.write(&matched_child.path)?;
1141                    context.writer.write("\"")?;
1142                }
1143                context.writer.write(" component=\"")?;
1144                context.writer.write(comp)?;
1145                context.writer.write("\"")?;
1146                if matched_child.exact {
1147                    context.writer.write(" exact")?;
1148                }
1149                route_renderer::write_route_pending_attrs(context.writer, matched_child)?;
1150                // Emit data-ri for O(1) client-side element binding
1151                let ri = context.route_chain_index;
1152                context.route_chain_index += 1;
1153                context.writer.write(" data-ri=\"")?;
1154                write_usize(context.writer, ri)?;
1155                context.writer.write("\" active>")?;
1156
1157                context.writer.write("<")?;
1158                context.writer.write(comp)?;
1159                if let Some(p) = &context.plugin {
1160                    p.write_route_component_state(context.state, context.writer)?;
1161                }
1162                prepare_generated_streaming_root(comp, context)?;
1163                context.writer.write(">")?;
1164
1165                self.process_component(
1166                    &webui_protocol::WebUIFragmentComponent {
1167                        fragment_id: comp.clone(),
1168                    },
1169                    ComponentHostOrigin::HandlerGenerated,
1170                    context,
1171                )?;
1172
1173                context.writer.write("</")?;
1174                context.writer.write(comp)?;
1175                context.writer.write(">")?;
1176                context.writer.write("</webui-route>")?;
1177
1178                context.route_base = saved_route_base;
1179                context.route_children = saved_route_children;
1180            }
1181        }
1182
1183        // Render non-matched siblings as hidden
1184        for (idx, child) in children.iter().enumerate() {
1185            let is_matched = best.as_ref().is_some_and(|(bi, _)| *bi == idx);
1186            if !is_matched && !child.fragment_id.is_empty() {
1187                context.writer.write("<webui-route")?;
1188                if !child.path.is_empty() {
1189                    context.writer.write(" path=\"")?;
1190                    context.writer.write(&child.path)?;
1191                    context.writer.write("\"")?;
1192                }
1193                context.writer.write(" component=\"")?;
1194                context.writer.write(&child.fragment_id)?;
1195                context.writer.write("\"")?;
1196                if child.exact {
1197                    context.writer.write(" exact")?;
1198                }
1199                route_renderer::write_route_pending_attrs(context.writer, child)?;
1200                context
1201                    .writer
1202                    .write(" style=\"display:none\"></webui-route>")?;
1203            }
1204        }
1205
1206        Ok(())
1207    }
1208
1209    /// Emit a `<script type="importmap">` tag that registers a component's
1210    /// CSS module under its specifier via a `data:text/css,…` URI.
1211    ///
1212    /// Requires Multiple Import Maps (Chrome 133+); each call emits an
1213    /// independent importmap that the browser merges at the document
1214    /// level. The per-render CSP nonce is applied when set (importmap
1215    /// scripts honor `script-src`).
1216    ///
1217    /// Example for `my-comp` with CSS `span{color:blue;}`:
1218    /// `<script type="importmap" nonce="...">{"imports":{"my-comp":"data:text/css,span{color:blue;}"}}</script>`
1219    fn emit_css_module_importmap(
1220        &self,
1221        specifier: &str,
1222        css: &str,
1223        context: &mut WebUIProcessContext,
1224    ) -> Result<()> {
1225        let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
1226        context.writer.write(&tag)?;
1227        Ok(())
1228    }
1229
1230    /// Emit a component's CSS module importmap on its first render
1231    /// (deduped by `rendered_components`) into the component's light DOM,
1232    /// so the browser registers it under the component's specifier
1233    /// before the shadow root template is parsed. See
1234    /// [`Self::emit_css_module_importmap`] for the emitted shape.
1235    ///
1236    /// Only components rendered on the current route get inline
1237    /// definitions; others receive theirs via `templateStyles` during
1238    /// SPA partial navigation.
1239    fn emit_css_module(
1240        &self,
1241        component: &webui_protocol::WebUIFragmentComponent,
1242        context: &mut WebUIProcessContext,
1243    ) -> Result<()> {
1244        let metadata_already_streamed = context.streaming.as_ref().is_some_and(|streaming| {
1245            streaming_template_already_sent(
1246                streaming,
1247                context.component_index,
1248                &component.fragment_id,
1249            )
1250        });
1251        if !metadata_already_streamed
1252            && !context.rendered_components.contains(&component.fragment_id)
1253        {
1254            if let Some(css) = context
1255                .protocol
1256                .components
1257                .get(&component.fragment_id)
1258                .map(|c| c.css.as_str())
1259                .filter(|s| !s.is_empty())
1260            {
1261                self.emit_css_module_importmap(&component.fragment_id, css, context)?;
1262            }
1263        }
1264        Ok(())
1265    }
1266
1267    /// Process a route fragment — renders `<webui-route>` with matched/hidden state.
1268    fn process_route(
1269        &self,
1270        route_frag: &webui_protocol::WebUiFragmentRoute,
1271        best_route: &Option<(String, route_matcher::RouteMatch)>,
1272        context: &mut WebUIProcessContext,
1273    ) -> Result<()> {
1274        let is_matched = best_route
1275            .as_ref()
1276            .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
1277
1278        context.writer.write("<webui-route")?;
1279        if !route_frag.path.is_empty() {
1280            context.writer.write(" path=\"")?;
1281            context.writer.write(&route_frag.path)?;
1282            context.writer.write("\"")?;
1283        }
1284        if !route_frag.fragment_id.is_empty() {
1285            context.writer.write(" component=\"")?;
1286            context.writer.write(&route_frag.fragment_id)?;
1287            context.writer.write("\"")?;
1288        }
1289        if route_frag.exact {
1290            context.writer.write(" exact")?;
1291        }
1292        route_renderer::write_route_pending_attrs(context.writer, route_frag)?;
1293
1294        if is_matched {
1295            // Emit data-ri for O(1) client-side element binding
1296            let ri = context.route_chain_index;
1297            context.route_chain_index += 1;
1298            context.writer.write(" data-ri=\"")?;
1299            write_usize(context.writer, ri)?;
1300            context.writer.write("\" active>")?;
1301
1302            if !route_frag.fragment_id.is_empty() {
1303                let saved_route_base = context.route_base.clone();
1304                let saved_route_children = std::mem::take(&mut context.route_children);
1305                if let Some((_, ref rm)) = best_route {
1306                    context.route_base = Cow::Owned(route_matcher::compute_route_base(
1307                        context.request_path,
1308                        rm.consumed_segments,
1309                    ));
1310                }
1311
1312                context.route_children = route_frag.children.clone();
1313
1314                let comp = webui_protocol::WebUIFragmentComponent {
1315                    fragment_id: route_frag.fragment_id.clone(),
1316                };
1317
1318                context.writer.write("<")?;
1319                context.writer.write(&route_frag.fragment_id)?;
1320                if let Some(p) = &context.plugin {
1321                    p.write_route_component_state(context.state, context.writer)?;
1322                }
1323                prepare_generated_streaming_root(&route_frag.fragment_id, context)?;
1324                context.writer.write(">")?;
1325
1326                self.process_component(&comp, ComponentHostOrigin::HandlerGenerated, context)?;
1327
1328                context.writer.write("</")?;
1329                context.writer.write(&route_frag.fragment_id)?;
1330                context.writer.write(">")?;
1331
1332                context.route_base = saved_route_base;
1333                context.route_children = saved_route_children;
1334            }
1335        } else {
1336            context.writer.write(" style=\"display:none\">")?;
1337        }
1338
1339        context.writer.write("</webui-route>")?;
1340        Ok(())
1341    }
1342
1343    /// Process a component fragment.
1344    fn process_component(
1345        &self,
1346        component: &webui_protocol::WebUIFragmentComponent,
1347        origin: ComponentHostOrigin,
1348        context: &mut WebUIProcessContext,
1349    ) -> Result<()> {
1350        if context.streaming.is_some() {
1351            consume_streaming_component_root(&component.fragment_id, origin, context)?;
1352            // Capture only after root parity succeeds, so malformed protocols
1353            // cannot contribute unmarked hosts to a checkpoint.
1354            record_checkpoint_tag(context, &component.fragment_id);
1355        }
1356
1357        // Emit the component's CSS module importmap into its light DOM and track
1358        // the component as rendered on first encounter only. `rendered_components`
1359        // is a set, so gating the `insert` (and its `String` clone) behind the
1360        // first-encounter check avoids allocating a throwaway `String` for every
1361        // duplicate instance while keeping the set contents identical.
1362        if !context.rendered_components.contains(&component.fragment_id) {
1363            self.emit_css_module(component, context)?;
1364            context
1365                .rendered_components
1366                .insert(component.fragment_id.clone());
1367        }
1368
1369        // Save parent scope. `mem::take` leaves an alloc-free empty map behind.
1370        let saved_local_vars = std::mem::take(&mut context.local_vars);
1371        // The component's accumulated attrs become its local vars; the next
1372        // sibling accumulates into a recycled (capacity-preserving) map from the
1373        // request-local pool instead of a freshly allocated `HashMap`.
1374        let saved_component_attrs = std::mem::replace(
1375            &mut context.component_attrs,
1376            take_scope_map(&mut context.scope_pool),
1377        );
1378        context.local_vars = saved_component_attrs;
1379
1380        if let Some(p) = &mut context.plugin {
1381            p.push_scope();
1382        }
1383
1384        self.process_fragment_id(&component.fragment_id, context)?;
1385
1386        if let Some(p) = &mut context.plugin {
1387            p.pop_scope();
1388        }
1389
1390        // Restore parent scope, recycling this component's local map (its
1391        // accumulated attrs) back into the pool so a sibling reuses its capacity.
1392        let used_locals = std::mem::replace(&mut context.local_vars, saved_local_vars);
1393        recycle_scope_map(&mut context.scope_pool, used_locals);
1394        // The attr accumulator (pulled from the pool above) is cleared for the
1395        // next sibling while retaining its bucket capacity.
1396        context.component_attrs.clear();
1397
1398        Ok(())
1399    }
1400
1401    /// Resolve a dotted path value, checking local variables first, then global state.
1402    fn resolve_value(
1403        &self,
1404        path: &str,
1405        context: &WebUIProcessContext<'_, '_, '_>,
1406    ) -> Option<Value> {
1407        resolve_value_from_sources(path, &context.local_vars, context.state).map(Cow::into_owned)
1408    }
1409
1410    /// Evaluate a condition expression against the current context.
1411    ///
1412    /// Uses a resolver closure that checks local variables first, then falls
1413    /// back to global state — avoiding a full clone of the state tree.
1414    /// Returns false if the condition references a missing value.
1415    fn evaluate_condition(
1416        &self,
1417        condition: &webui_protocol::ConditionExpr,
1418        context: &WebUIProcessContext,
1419    ) -> Result<bool> {
1420        let local_vars = &context.local_vars;
1421        let state = context.state;
1422        match evaluate_with_resolver(condition, |path| {
1423            resolve_value_from_sources(path, local_vars, state)
1424        }) {
1425            Ok(result) => Ok(result),
1426            Err(ExpressionError::MissingValue(_)) => Ok(false),
1427            Err(e) => Err(HandlerError::Evaluation(e.to_string())),
1428        }
1429    }
1430
1431    /// Process a for loop fragment.
1432    ///
1433    /// Creates a new context for each iteration that includes the current loop item.
1434    /// This allows nested templates to access both the loop variable and any parent context.
1435    /// Example: `for item in items` makes "item" available in the loop body.
1436    fn process_for_loop(
1437        &self,
1438        for_loop: &webui_protocol::WebUIFragmentFor,
1439        context: &mut WebUIProcessContext,
1440    ) -> Result<()> {
1441        let collection_name = &for_loop.collection;
1442
1443        // If the collection is missing, treat it as empty (0 iterations) — matches NodeJS behavior.
1444        // Hydration comments are always emitted regardless of collection presence.
1445        let items = match self.resolve_value(collection_name, context) {
1446            Some(Value::Array(arr)) => arr,
1447            Some(_) => {
1448                return Err(HandlerError::TypeError(format!(
1449                    "Collection '{}' is not an array",
1450                    collection_name
1451                )))
1452            }
1453            None => Vec::new(),
1454        };
1455
1456        if let Some(p) = &mut context.plugin {
1457            p.on_for_start(&for_loop.fragment_id, context.writer)?;
1458        }
1459
1460        // Hot-loop optimisation: the loop variable name is `String`-keyed
1461        // in `local_vars`. The naive impl re-inserts (and so re-allocates
1462        // the key) on every iteration — a 1000-item loop pays 2000 String
1463        // clones for the key alone. Instead, we save the outer-scope
1464        // value (if any) ONCE before the loop, install the key ONCE with
1465        // an empty placeholder, then overwrite the value in-place each
1466        // iteration via `get_mut`. Restoration at the end happens once.
1467        let item_name = for_loop.item.as_str();
1468        let saved_value = context.local_vars.remove(item_name);
1469        // Pre-insert the key so per-iteration `get_mut` is infallible.
1470        // Cost: at most one `String::from(item_name)` for the lifetime
1471        // of the loop, regardless of iteration count.
1472        if !items.is_empty() {
1473            context
1474                .local_vars
1475                .insert(item_name.to_string(), Value::Null);
1476        }
1477        for (i, item) in items.into_iter().enumerate() {
1478            if let Some(p) = &mut context.plugin {
1479                p.on_repeat_item_start(i, context.writer)?;
1480                p.push_scope();
1481            }
1482
1483            // O(1) value swap; no key allocation.
1484            if let Some(slot) = context.local_vars.get_mut(item_name) {
1485                *slot = item;
1486            }
1487            self.process_fragment_id(&for_loop.fragment_id, context)?;
1488
1489            if let Some(p) = &mut context.plugin {
1490                p.pop_scope();
1491                p.on_repeat_item_end(i, context.writer)?;
1492            }
1493        }
1494        // Restore outer scope (or remove the placeholder we installed).
1495        match saved_value {
1496            Some(value) => {
1497                context.local_vars.insert(item_name.to_string(), value);
1498            }
1499            None => {
1500                context.local_vars.remove(item_name);
1501            }
1502        }
1503
1504        if let Some(p) = &mut context.plugin {
1505            p.on_for_end(&for_loop.fragment_id, context.writer)?;
1506        }
1507
1508        Ok(())
1509    }
1510
1511    /// Process a signal fragment.
1512    ///
1513    /// Looks up the value in the context first (for local variables), then in the global state.
1514    /// This prioritization allows local variables (like loop items) to override global state.
1515    /// If the value is not found in either scope, an empty string is returned.
1516    #[inline]
1517    fn process_signal<'data>(
1518        &self,
1519        signal: &'data webui_protocol::WebUIFragmentSignal,
1520        context: &mut WebUIProcessContext<'data, '_, '_>,
1521    ) -> Result<()> {
1522        if signal.raw {
1523            self.process_raw_signal(signal, context)
1524        } else {
1525            self.process_state_signal(signal, context)
1526        }
1527    }
1528
1529    #[inline(never)]
1530    fn process_raw_signal<'data>(
1531        &self,
1532        signal: &'data webui_protocol::WebUIFragmentSignal,
1533        context: &mut WebUIProcessContext<'data, '_, '_>,
1534    ) -> Result<()> {
1535        let Some(structural_value) = structural_signal_value(signal) else {
1536            return self.process_state_signal(signal, context);
1537        };
1538
1539        if context.streaming.is_some()
1540            && self.process_streaming_signal(structural_value, context)?
1541        {
1542            return Ok(());
1543        }
1544
1545        // Hook: emit nonce meta and CSS <link> tags before </head>.
1546        // Guarded by `head_end_emitted` so a malformed protocol cannot
1547        // emit nonce/preloads/inject more than once per render.
1548        if structural_value == "head_end" && !context.head_end_emitted {
1549            context.head_end_emitted = true;
1550            if let Some(nonce) = context.nonce {
1551                context
1552                    .writer
1553                    .write("<meta name=\"webui-nonce\" content=\"")?;
1554                context
1555                    .writer
1556                    .write(&crate::html_encode::encode_safe(nonce))?;
1557                context.writer.write("\">")?;
1558            }
1559
1560            // Emit CSS <link> tags in <head> for Link-strategy components.
1561            // For components with a non-empty css_href:
1562            //   Link + Shadow → <link rel="preload"> (stylesheet is in shadow root)
1563            //   Link + Light  → <link rel="stylesheet"> (no shadow root)
1564            //
1565            // Style and Module strategies emit their CSS during component
1566            // rendering (shadow-DOM template / importmap respectively).
1567            let is_link = context.protocol.css_strategy() == webui_protocol::CssStrategy::Link;
1568            let is_shadow = context.protocol.dom_strategy() == webui_protocol::DomStrategy::Shadow;
1569
1570            if is_link {
1571                let (needed_components, _) =
1572                    crate::route_handler::get_needed_components_for_request(
1573                        context.protocol,
1574                        context.entry_id,
1575                        context.request_path,
1576                        "",
1577                        (context.component_index, context.route_index),
1578                    )?;
1579
1580                for name in &needed_components {
1581                    if let Some(href) = context
1582                        .protocol
1583                        .components
1584                        .get(name)
1585                        .map(|c| c.css_href.as_str())
1586                        .filter(|h| !h.is_empty())
1587                    {
1588                        if is_shadow {
1589                            context.writer.write("<link rel=\"preload\" href=\"")?;
1590                            context.writer.write(href)?;
1591                            context
1592                                .writer
1593                                .write("\" as=\"style\" data-webui-ssr-preload=\"style\">")?;
1594                        } else {
1595                            context.writer.write("<link rel=\"stylesheet\" href=\"")?;
1596                            context.writer.write(href)?;
1597                            context.writer.write("\">")?;
1598                        }
1599                    }
1600                }
1601            }
1602
1603            // Compiler-resolved `modulepreload` hints for the shared chunks
1604            // the page's module entries statically import. Those chunks are
1605            // named only inside the entry's own bytes, so without this the
1606            // browser must download and parse the entry before it can even
1607            // discover them.
1608            //
1609            // Emitted after the CSS links on purpose: CSS is render-blocking
1610            // and owns first paint, while these own first interaction, so the
1611            // stylesheet requests go out first. The list arrives pre-ordered
1612            // (largest chunk first) and pre-resolved from the build, so this
1613            // is a straight write with no per-request work.
1614            if !context.protocol.module_preloads.is_empty() {
1615                for href in &context.protocol.module_preloads {
1616                    context
1617                        .writer
1618                        .write("<link rel=\"modulepreload\" href=\"")?;
1619                    context.writer.write(href)?;
1620                    context.writer.write("\">")?;
1621                }
1622            }
1623
1624            // Per-render `head_inject` HTML — image preloads, A/B test
1625            // markers, etc. supplied by the host via RenderOptions.
1626            // Emitted at the structural head_end boundary, after the
1627            // built-in nonce + CSS-link emissions, so host injects
1628            // appear immediately before `</head>`.
1629            if let Some(html) = context.head_inject {
1630                context.writer.write(html)?;
1631            }
1632
1633            // Reserved-state `headEnd` HTML, last at this boundary so a
1634            // host that sets both channels gets a deterministic order:
1635            // built-in emissions, then `RenderOptions`, then state.
1636            if let Some(html) = context.state_inject.head_end {
1637                context.writer.write(html)?;
1638            }
1639        }
1640
1641        // Hook: emit state-supplied HTML immediately after `<body>`.
1642        // Guarded by its own dedup flag so a malformed protocol emitting
1643        // the signal twice cannot duplicate the injected markup.
1644        if structural_value == "body_start" && !context.body_start_emitted {
1645            context.body_start_emitted = true;
1646            if let Some(html) = context.state_inject.body_start {
1647                context.writer.write(html)?;
1648            }
1649        }
1650
1651        // Hook: emit component templates and host body_inject before </body>.
1652        // Single guarded block so the dedup flag protects both the
1653        // hydration emission and the host inject from a malformed
1654        // protocol that fires `body_end` more than once per render.
1655        if structural_value == "body_end" && !context.body_end_emitted {
1656            context.body_end_emitted = true;
1657            if context.plugin.is_some() {
1658                // Emit templates for all REACHABLE components on the current route,
1659                // not just those rendered in this SSR pass. Components inside false
1660                // <if> blocks or empty <for> loops are reachable via client-side
1661                // state changes and need their templates available without a server
1662                // round-trip. The graph walker follows conditional and loop branches
1663                // unconditionally, but only descends into the matched route chain —
1664                // components on other routes are delivered via SPA partial navigation.
1665                let reachable = crate::route_handler::collect_reachable_components_for_request(
1666                    context.protocol,
1667                    context.entry_id,
1668                    context.request_path,
1669                    context.route_index,
1670                );
1671                let state_selection =
1672                    collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
1673
1674                // Emit CSS module importmaps for reachable-but-unrendered
1675                // components so the framework can adopt them when an `<if>`
1676                // condition flips true client-side.
1677                for name in &reachable {
1678                    if !context.rendered_components.contains(name) {
1679                        if let Some(css) = context
1680                            .protocol
1681                            .components
1682                            .get(name)
1683                            .map(|c| c.css.as_str())
1684                            .filter(|s| !s.is_empty())
1685                        {
1686                            self.emit_css_module_importmap(name, css, context)?;
1687                        }
1688                    }
1689                }
1690
1691                // Try to collect split WebUI template payloads. If the plugin
1692                // returns None (non-WebUI templates, e.g. FAST), fall back to
1693                // separate emission.
1694                let template_payloads = context
1695                    .plugin
1696                    .as_ref()
1697                    .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
1698
1699                if template_payloads.is_none() {
1700                    // Non-JS templates (FAST plugins) - emit separately
1701                    if let Some(ref p) = context.plugin {
1702                        p.emit_templates(
1703                            context.protocol,
1704                            &reachable,
1705                            context.nonce,
1706                            context.writer,
1707                        )?;
1708                    }
1709                }
1710
1711                // Compute the inventory hex from actually rendered components.
1712                let inventory_hex = crate::route_handler::encode_component_inventory(
1713                    &context.rendered_components,
1714                    context.component_index,
1715                );
1716
1717                // Chain
1718                let chain = crate::route_handler::collect_route_chain(
1719                    context.protocol,
1720                    context.entry_id,
1721                    context.request_path,
1722                    context.route_index,
1723                );
1724                let chain_json: Vec<Value> = chain
1725                    .iter()
1726                    .map(crate::route_handler::RouteChainEntry::to_json)
1727                    .collect();
1728
1729                // CSS hrefs emitted during SSR (Link-strategy components)
1730                let is_link = context.protocol.css_strategy() == webui_protocol::CssStrategy::Link;
1731                let mut css_hrefs: Vec<&str> = Vec::new();
1732                if is_link {
1733                    for name in &reachable {
1734                        if let Some(href) = context
1735                            .protocol
1736                            .components
1737                            .get(name)
1738                            .map(|c| c.css_href.as_str())
1739                            .filter(|h| !h.is_empty())
1740                        {
1741                            css_hrefs.push(href);
1742                        }
1743                    }
1744                }
1745
1746                // Module style specifiers emitted during SSR
1747                let mut style_specs: Vec<&str> = Vec::new();
1748                for name in &reachable {
1749                    if context
1750                        .protocol
1751                        .components
1752                        .get(name)
1753                        .map(|c| !c.css.is_empty())
1754                        .unwrap_or(false)
1755                    {
1756                        style_specs.push(name);
1757                    }
1758                }
1759
1760                let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
1761                let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
1762                write_webui_data_block(
1763                    context.writer,
1764                    &mut context.json_scratch,
1765                    WebUiBootstrap {
1766                        state: context.state,
1767                        state_selection,
1768                        chain: &chain_json,
1769                        inventory: &inventory_hex,
1770                        nonce: context.nonce,
1771                        css_hrefs: &css_hrefs,
1772                        style_specs: &style_specs,
1773                        templates: payloads,
1774                    },
1775                )?;
1776
1777                // Let the active plugin emit any framework-specific executable
1778                // side channel. FAST plugins default to no-op; WebUI installs
1779                // templateFns. Client packages parse #webui-data lazily.
1780                if let Some(ref plugin) = context.plugin {
1781                    plugin.emit_bootstrap_extension(
1782                        BootstrapExtensionContext {
1783                            protocol: context.protocol,
1784                            components: &reachable,
1785                            payloads,
1786                            nonce: context.nonce,
1787                        },
1788                        context.writer,
1789                    )?;
1790                }
1791            }
1792
1793            // Per-render `body_inject` HTML — dev livereload script,
1794            // analytics, etc. supplied by the host via RenderOptions.
1795            // Inside the dedup block but outside the plugin-only
1796            // sub-block above, so it fires regardless of whether a
1797            // hydration plugin is active. Appears immediately before
1798            // `</body>`.
1799            if let Some(html) = context.body_inject {
1800                context.writer.write(html)?;
1801            }
1802
1803            // Reserved-state `bodyEnd` HTML, last at this boundary. Same
1804            // precedence as `head_end`: built-ins, `RenderOptions`, state.
1805            if let Some(html) = context.state_inject.body_end {
1806                context.writer.write(html)?;
1807            }
1808        }
1809
1810        // Structural signals are never state lookups. In particular, ordinary
1811        // rendering ignores boundary/root markers byte-for-byte, while authored
1812        // raw bindings with the same visible key remain ordinary state.
1813        Ok(())
1814    }
1815
1816    #[inline]
1817    fn process_state_signal(
1818        &self,
1819        signal: &webui_protocol::WebUIFragmentSignal,
1820        context: &mut WebUIProcessContext,
1821    ) -> Result<()> {
1822        if let Some(p) = &mut context.plugin {
1823            p.on_binding_start(&signal.value, context.writer)?;
1824        }
1825
1826        if let Some(value) = self.resolve_value(&signal.value, context) {
1827            self.write_signal_value(&value, signal.raw, context.writer)?;
1828        }
1829
1830        if let Some(p) = &mut context.plugin {
1831            p.on_binding_end(&signal.value, context.writer)?;
1832        }
1833        Ok(())
1834    }
1835
1836    /// Write a signal value directly to the writer, avoiding intermediate String allocation.
1837    /// For HTML-escaped output, writes the Cow from `encode_safe` directly.
1838    fn write_signal_value(
1839        &self,
1840        value: &Value,
1841        raw: bool,
1842        writer: &mut dyn ResponseWriter,
1843    ) -> Result<()> {
1844        if raw {
1845            match value {
1846                Value::String(s) => writer.write(s),
1847                _ => writer.write(&value.to_string()),
1848            }
1849        } else {
1850            match value {
1851                Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
1852                _ => {
1853                    let s = value.to_string();
1854                    writer.write(&crate::html_encode::encode_safe(&s))
1855                }
1856            }
1857        }
1858    }
1859
1860    /// Process an if condition fragment.
1861    fn process_if(
1862        &self,
1863        if_cond: &webui_protocol::WebUIFragmentIf,
1864        context: &mut WebUIProcessContext,
1865    ) -> Result<()> {
1866        let condition = if_cond
1867            .condition
1868            .as_ref()
1869            .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
1870        let condition_met = self.evaluate_condition(condition, context)?;
1871
1872        if let Some(p) = &mut context.plugin {
1873            p.on_if_start(&if_cond.fragment_id, context.writer)?;
1874        }
1875
1876        if condition_met {
1877            if let Some(p) = &mut context.plugin {
1878                p.push_scope();
1879            }
1880
1881            self.process_fragment_id(&if_cond.fragment_id, context)?;
1882
1883            if let Some(p) = &mut context.plugin {
1884                p.pop_scope();
1885            }
1886        }
1887
1888        if let Some(p) = &mut context.plugin {
1889            p.on_if_end(&if_cond.fragment_id, context.writer)?;
1890        }
1891
1892        Ok(())
1893    }
1894
1895    /// Process an attribute fragment by rendering the attribute name/value pair.
1896    fn process_attribute(
1897        &self,
1898        attr: &webui_protocol::WebUIFragmentAttribute,
1899        context: &mut WebUIProcessContext,
1900    ) -> Result<()> {
1901        // Initialize component attribute accumulator on attrStart
1902        if attr.attr_start {
1903            context.component_attrs = HashMap::new();
1904        }
1905
1906        // Boolean attribute with condition tree
1907        if let Some(condition) = &attr.condition_tree {
1908            let condition_met = self.evaluate_condition(condition, context)?;
1909
1910            if !attr.attr_skip {
1911                let name = component_attr_name(&attr.name);
1912                context
1913                    .component_attrs
1914                    .insert(name, Value::Bool(condition_met));
1915            }
1916
1917            if condition_met {
1918                context.writer.write(" ")?;
1919                context.writer.write(&attr.name)?;
1920            }
1921            return Ok(());
1922        }
1923
1924        // Template attribute (mixed static + dynamic)
1925        if !attr.template.is_empty() {
1926            let raw_value = self.render_template_attr_value(&attr.template, context)?;
1927            let escaped = crate::html_encode::encode_safe(&raw_value);
1928            write_attr(context.writer, &attr.name, &escaped)?;
1929
1930            if !attr.attr_skip {
1931                let name = component_attr_name(&attr.name);
1932                context
1933                    .component_attrs
1934                    .insert(name, Value::String(raw_value));
1935            }
1936            return Ok(());
1937        }
1938
1939        // Simple attribute
1940        if !attr.value.is_empty() {
1941            if attr.raw_value {
1942                // Static attribute — value is the literal string
1943                write_attr(context.writer, &attr.name, &attr.value)?;
1944                if !attr.attr_skip {
1945                    let name = component_attr_name(&attr.name);
1946                    context
1947                        .component_attrs
1948                        .insert(name, Value::String(attr.value.clone()));
1949                }
1950            } else if attr.complex {
1951                // Complex attribute — resolve value, don't render to HTML, store as state
1952                if let Some(value) = self.resolve_value(&attr.value, context) {
1953                    if !attr.attr_skip {
1954                        let stripped = attr.name.strip_prefix(':').unwrap_or(&attr.name);
1955                        let name = component_attr_name(stripped);
1956                        context.component_attrs.insert(name, value);
1957                    }
1958                }
1959            } else {
1960                // Dynamic attribute — resolve and render
1961                let value = self.resolve_value(&attr.value, context);
1962                // Always emit the attribute so FAST hydration markers
1963                // (`data-fe`) match the DOM node structure.
1964                match &value {
1965                    Some(Value::String(s)) => {
1966                        write_attr(
1967                            context.writer,
1968                            &attr.name,
1969                            &crate::html_encode::encode_safe(s),
1970                        )?;
1971                    }
1972                    Some(Value::Null) | None => {
1973                        write_attr(context.writer, &attr.name, "")?;
1974                    }
1975                    Some(other) => {
1976                        let s = other.to_string();
1977                        write_attr(
1978                            context.writer,
1979                            &attr.name,
1980                            &crate::html_encode::encode_safe(&s),
1981                        )?;
1982                    }
1983                }
1984
1985                if !attr.attr_skip {
1986                    let name = component_attr_name(&attr.name);
1987                    context
1988                        .component_attrs
1989                        .insert(name, value.unwrap_or(Value::String(String::new())));
1990                }
1991            }
1992        }
1993
1994        Ok(())
1995    }
1996
1997    /// Render a template attribute's fragments into a raw (unescaped) string.
1998    fn render_template_attr_value(
1999        &self,
2000        template_id: &str,
2001        context: &WebUIProcessContext,
2002    ) -> Result<String> {
2003        let fragments = context
2004            .protocol
2005            .fragments
2006            .get(template_id)
2007            .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
2008        let mut raw_value = String::new();
2009        for frag in &fragments.fragments {
2010            match frag.fragment.as_ref() {
2011                Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
2012                Some(Fragment::Signal(signal)) => {
2013                    if let Some(value) = self.resolve_value(&signal.value, context) {
2014                        match &value {
2015                            Value::String(s) => raw_value.push_str(s),
2016                            _ => raw_value.push_str(&value.to_string()),
2017                        }
2018                    }
2019                }
2020                _ => {}
2021            }
2022        }
2023        Ok(raw_value)
2024    }
2025
2026    /// Render the UI based on the protocol and state.
2027    pub fn render<'a>(
2028        &self,
2029        protocol: &'a Protocol,
2030        state: &'a Value,
2031        options: &RenderOptions<'a>,
2032        writer: &'a mut dyn ResponseWriter,
2033    ) -> Result<()> {
2034        let document = protocol.protocol();
2035        if !document.fragments.contains_key(options.entry_id) {
2036            return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
2037        }
2038        let mut context = WebUIProcessContext {
2039            protocol: document,
2040            state,
2041            writer,
2042            local_vars: HashMap::new(),
2043            component_attrs: HashMap::new(),
2044            request_path: options.request_path,
2045            route_base: Cow::Borrowed("/"),
2046            rendered_components: HashSet::new(),
2047            plugin: self.plugin_factory.map(|f| f()),
2048            route_children: Vec::new(),
2049            entry_id: options.entry_id,
2050            // Same defensive normalisation as `handle()`. See the
2051            // doc-comment there for the CSP-outage rationale.
2052            nonce: options.nonce.filter(|s| !s.is_empty()),
2053            head_inject: options.head_inject.filter(|s| !s.is_empty()),
2054            body_inject: options.body_inject.filter(|s| !s.is_empty()),
2055            state_inject: StateInject::resolve(state),
2056            head_end_emitted: false,
2057            body_start_emitted: false,
2058            component_index: protocol.component_index(),
2059            body_end_emitted: false,
2060            route_index: protocol.route_index(),
2061            route_chain_index: 0,
2062            streaming: None,
2063            json_scratch: Vec::new(),
2064            scope_pool: Vec::new(),
2065        };
2066
2067        self.process_fragment_id(options.entry_id, &mut context)?;
2068        writer.end()?;
2069
2070        Ok(())
2071    }
2072}
2073
2074impl Default for WebUIHandler {
2075    fn default() -> Self {
2076        Self::new()
2077    }
2078}
2079
2080/// Write ` name="value"` to the writer without allocating a format string.
2081fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
2082    writer.write(" ")?;
2083    writer.write(name)?;
2084    writer.write("=\"")?;
2085    writer.write(value)?;
2086    writer.write("\"")
2087}
2088
2089#[cfg(test)]
2090fn handle(
2091    protocol: &WebUIProtocol,
2092    state: &Value,
2093    options: &RenderOptions<'_>,
2094    writer: &mut dyn ResponseWriter,
2095) -> Result<()> {
2096    let handler = WebUIHandler::new();
2097    handler.handle(protocol, state, options, writer)
2098}
2099
2100#[cfg(test)]
2101mod tests {
2102    use super::*;
2103    use crate::streaming::STREAMING_MARKER;
2104    use std::cell::RefCell;
2105    use std::sync::Arc;
2106    use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
2107    use webui_protocol::{
2108        web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
2109        WebUIFragmentAttribute,
2110    };
2111    use webui_test_utils::test_json;
2112
2113    fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
2114        WebUIFragment::signal(
2115            format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
2116            true,
2117        )
2118    }
2119
2120    // A simple test writer implementation
2121    struct TestWriter {
2122        content: RefCell<String>,
2123        ended: RefCell<bool>,
2124    }
2125
2126    impl TestWriter {
2127        fn new() -> Self {
2128            Self {
2129                content: RefCell::new(String::new()),
2130                ended: RefCell::new(false),
2131            }
2132        }
2133
2134        fn get_content(&self) -> String {
2135            self.content.borrow().clone()
2136        }
2137
2138        fn is_ended(&self) -> bool {
2139            *self.ended.borrow()
2140        }
2141    }
2142
2143    impl ResponseWriter for TestWriter {
2144        fn write(&mut self, content: &str) -> Result<()> {
2145            self.content.borrow_mut().push_str(content);
2146            Ok(())
2147        }
2148
2149        fn end(&mut self) -> Result<()> {
2150            *self.ended.borrow_mut() = true;
2151            Ok(())
2152        }
2153    }
2154
2155    #[test]
2156    fn test_handle_raw() {
2157        // Create a simple protocol
2158        let mut fragments = HashMap::new();
2159        fragments.insert(
2160            "index.html".to_string(),
2161            FragmentList {
2162                fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
2163            },
2164        );
2165
2166        let protocol = WebUIProtocol::new(fragments);
2167        let state = test_json!({});
2168
2169        // Create a test writer
2170        let mut writer = TestWriter::new();
2171
2172        // Handle the protocol
2173        assert!(
2174            handle(
2175                &protocol,
2176                &state,
2177                &RenderOptions::new("index.html", "/"),
2178                &mut writer
2179            )
2180            .is_ok(),
2181            "Failed to handle raw protocol"
2182        );
2183
2184        // Check the output
2185        assert_eq!(writer.get_content(), "Hello, WebUI!");
2186        assert!(writer.is_ended());
2187    }
2188
2189    #[test]
2190    fn test_handle_signal() {
2191        // Create a protocol with a signal
2192        let mut fragments = HashMap::new();
2193        fragments.insert(
2194            "index.html".to_string(),
2195            FragmentList {
2196                fragments: vec![
2197                    WebUIFragment::raw("Hello, "),
2198                    WebUIFragment::signal("name", false),
2199                    WebUIFragment::raw("!"),
2200                ],
2201            },
2202        );
2203
2204        let protocol = WebUIProtocol::new(fragments);
2205        let state = test_json!({"name": "WebUI"});
2206
2207        // Create a test writer
2208        let mut writer = TestWriter::new();
2209
2210        // Handle the protocol
2211        assert!(
2212            handle(
2213                &protocol,
2214                &state,
2215                &RenderOptions::new("index.html", "/"),
2216                &mut writer
2217            )
2218            .is_ok(),
2219            "Failed to handle signal protocol"
2220        );
2221
2222        // Check the output
2223        assert_eq!(writer.get_content(), "Hello, WebUI!");
2224        assert!(writer.is_ended());
2225    }
2226
2227    #[test]
2228    fn test_handle_for_loop() {
2229        // Create a protocol with a for loop
2230        let mut fragments = HashMap::new();
2231        fragments.insert(
2232            "index.html".to_string(),
2233            FragmentList {
2234                fragments: vec![
2235                    WebUIFragment::raw("People: "),
2236                    WebUIFragment::for_loop("person", "people", "person-item"),
2237                ],
2238            },
2239        );
2240
2241        fragments.insert(
2242            "person-item".to_string(),
2243            FragmentList {
2244                fragments: vec![
2245                    WebUIFragment::signal("person.name", false),
2246                    WebUIFragment::raw(", "),
2247                ],
2248            },
2249        );
2250
2251        let protocol = WebUIProtocol::new(fragments);
2252        let state = test_json!({
2253            "people": [
2254                {"name": "Alice"},
2255                {"name": "Bob"},
2256                {"name": "Charlie"}
2257            ]
2258        });
2259
2260        // Create a test writer
2261        let mut writer = TestWriter::new();
2262
2263        // Handle the protocol
2264        assert!(
2265            handle(
2266                &protocol,
2267                &state,
2268                &RenderOptions::new("index.html", "/"),
2269                &mut writer
2270            )
2271            .is_ok(),
2272            "Failed to handle for loop protocol"
2273        );
2274
2275        // Check the output
2276        assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
2277        assert!(writer.is_ended());
2278    }
2279
2280    #[test]
2281    fn test_handle_if_condition() {
2282        // Create a protocol with an if condition
2283        let mut fragments = HashMap::new();
2284        fragments.insert(
2285            "index.html".to_string(),
2286            FragmentList {
2287                fragments: vec![
2288                    WebUIFragment::raw("Status: "),
2289                    WebUIFragment::if_cond(
2290                        webui_protocol::ConditionExpr::identifier("isActive"),
2291                        "active-content",
2292                    ),
2293                    WebUIFragment::raw("End"),
2294                ],
2295            },
2296        );
2297
2298        fragments.insert(
2299            "active-content".to_string(),
2300            FragmentList {
2301                fragments: vec![WebUIFragment::raw("Active")],
2302            },
2303        );
2304
2305        let protocol = WebUIProtocol::new(fragments);
2306
2307        // Test with isActive = true
2308        let state_true = test_json!({"isActive": true});
2309        let mut writer_true = TestWriter::new();
2310        assert!(
2311            handle(
2312                &protocol,
2313                &state_true,
2314                &RenderOptions::new("index.html", "/"),
2315                &mut writer_true
2316            )
2317            .is_ok(),
2318            "Failed to handle if condition (true case)"
2319        );
2320        assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
2321        assert!(writer_true.is_ended());
2322
2323        // Test with isActive = false
2324        let state_false = test_json!({"isActive": false});
2325        let mut writer_false = TestWriter::new();
2326        assert!(
2327            handle(
2328                &protocol,
2329                &state_false,
2330                &RenderOptions::new("index.html", "/"),
2331                &mut writer_false
2332            )
2333            .is_ok(),
2334            "Failed to handle if condition (false case)"
2335        );
2336        assert_eq!(writer_false.get_content(), "Status: End");
2337        assert!(writer_false.is_ended());
2338    }
2339
2340    #[test]
2341    fn test_handle_component() {
2342        // Create a protocol with a component
2343        let mut fragments = HashMap::new();
2344        fragments.insert(
2345            "index.html".to_string(),
2346            FragmentList {
2347                fragments: vec![
2348                    WebUIFragment::raw("Component: "),
2349                    WebUIFragment::component("my-component"),
2350                ],
2351            },
2352        );
2353
2354        fragments.insert(
2355            "my-component".to_string(),
2356            FragmentList {
2357                fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
2358            },
2359        );
2360
2361        let protocol = WebUIProtocol::new(fragments);
2362        let state = test_json!({});
2363
2364        // Create a test writer
2365        let mut writer = TestWriter::new();
2366
2367        // Handle the protocol
2368        assert!(
2369            handle(
2370                &protocol,
2371                &state,
2372                &RenderOptions::new("index.html", "/"),
2373                &mut writer
2374            )
2375            .is_ok(),
2376            "Failed to handle component protocol"
2377        );
2378
2379        // Check the output
2380        assert_eq!(
2381            writer.get_content(),
2382            "Component: <div>Component Content</div>"
2383        );
2384        assert!(writer.is_ended());
2385    }
2386
2387    #[test]
2388    fn test_missing_fragment() {
2389        // Create a protocol with a missing fragment reference
2390        let mut fragments = HashMap::new();
2391        fragments.insert(
2392            "index.html".to_string(),
2393            FragmentList {
2394                fragments: vec![WebUIFragment::component("missing-component")],
2395            },
2396        );
2397
2398        let protocol = WebUIProtocol::new(fragments);
2399        let state = test_json!({});
2400
2401        // Create a test writer
2402        let mut writer = TestWriter::new();
2403
2404        // Handle the protocol
2405        let result = handle(
2406            &protocol,
2407            &state,
2408            &RenderOptions::new("index.html", "/"),
2409            &mut writer,
2410        );
2411
2412        // Expect an error
2413        assert!(result.is_err());
2414        if let Err(HandlerError::MissingFragment(fragment_id)) = result {
2415            assert_eq!(fragment_id, "missing-component");
2416        } else {
2417            panic!("Expected MissingFragment error");
2418        }
2419    }
2420
2421    #[test]
2422    fn test_missing_signal_renders_empty() {
2423        // A signal referencing a field absent from state should render as empty
2424        let mut fragments = HashMap::new();
2425        fragments.insert(
2426            "index.html".to_string(),
2427            FragmentList {
2428                fragments: vec![
2429                    WebUIFragment::raw("Hello, "),
2430                    WebUIFragment::signal("missing_field", false),
2431                    WebUIFragment::raw("!"),
2432                ],
2433            },
2434        );
2435
2436        let protocol = WebUIProtocol::new(fragments);
2437        let state = test_json!({});
2438
2439        let mut writer = TestWriter::new();
2440
2441        assert!(
2442            handle(
2443                &protocol,
2444                &state,
2445                &RenderOptions::new("index.html", "/"),
2446                &mut writer
2447            )
2448            .is_ok(),
2449            "Missing signal should not produce an error"
2450        );
2451
2452        assert_eq!(writer.get_content(), "Hello, !");
2453        assert!(writer.is_ended());
2454    }
2455
2456    // ── Boolean attribute rendering tests ─────────────────────────────
2457
2458    #[test]
2459    fn test_boolean_attr_true() {
2460        let mut fragments = HashMap::new();
2461        fragments.insert(
2462            "index.html".to_string(),
2463            FragmentList {
2464                fragments: vec![
2465                    WebUIFragment::raw("<button"),
2466                    WebUIFragment::attribute_boolean(
2467                        "disabled",
2468                        ConditionExpr::identifier("isDisabled"),
2469                    ),
2470                    WebUIFragment::raw(">Click</button>"),
2471                ],
2472            },
2473        );
2474        let protocol = WebUIProtocol::new(fragments);
2475        let state = test_json!({"isDisabled": true});
2476        let mut writer = TestWriter::new();
2477        handle(
2478            &protocol,
2479            &state,
2480            &RenderOptions::new("index.html", "/"),
2481            &mut writer,
2482        )
2483        .unwrap();
2484        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
2485    }
2486
2487    #[test]
2488    fn test_boolean_attr_false() {
2489        let mut fragments = HashMap::new();
2490        fragments.insert(
2491            "index.html".to_string(),
2492            FragmentList {
2493                fragments: vec![
2494                    WebUIFragment::raw("<button"),
2495                    WebUIFragment::attribute_boolean(
2496                        "disabled",
2497                        ConditionExpr::identifier("isDisabled"),
2498                    ),
2499                    WebUIFragment::raw(">Click</button>"),
2500                ],
2501            },
2502        );
2503        let protocol = WebUIProtocol::new(fragments);
2504        let state = test_json!({"isDisabled": false});
2505        let mut writer = TestWriter::new();
2506        handle(
2507            &protocol,
2508            &state,
2509            &RenderOptions::new("index.html", "/"),
2510            &mut writer,
2511        )
2512        .unwrap();
2513        assert_eq!(writer.get_content(), "<button>Click</button>");
2514    }
2515
2516    #[test]
2517    fn test_boolean_attr_missing() {
2518        let mut fragments = HashMap::new();
2519        fragments.insert(
2520            "index.html".to_string(),
2521            FragmentList {
2522                fragments: vec![
2523                    WebUIFragment::raw("<input type=\"checkbox\""),
2524                    WebUIFragment::attribute_boolean(
2525                        "checked",
2526                        ConditionExpr::identifier("checked"),
2527                    ),
2528                    WebUIFragment::raw(">"),
2529                ],
2530            },
2531        );
2532        let protocol = WebUIProtocol::new(fragments);
2533        let state = test_json!({});
2534        let mut writer = TestWriter::new();
2535        handle(
2536            &protocol,
2537            &state,
2538            &RenderOptions::new("index.html", "/"),
2539            &mut writer,
2540        )
2541        .unwrap();
2542        assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
2543    }
2544
2545    #[test]
2546    fn test_boolean_attr_multiple() {
2547        let mut fragments = HashMap::new();
2548        fragments.insert(
2549            "index.html".to_string(),
2550            FragmentList {
2551                fragments: vec![
2552                    WebUIFragment::raw("<input type=\"checkbox\""),
2553                    WebUIFragment::attribute_boolean(
2554                        "checked",
2555                        ConditionExpr::identifier("checked"),
2556                    ),
2557                    WebUIFragment::attribute_boolean(
2558                        "disabled",
2559                        ConditionExpr::identifier("disabled"),
2560                    ),
2561                    WebUIFragment::raw(">"),
2562                ],
2563            },
2564        );
2565        let protocol = WebUIProtocol::new(fragments);
2566        let state = test_json!({"checked": true, "disabled": false});
2567        let mut writer = TestWriter::new();
2568        handle(
2569            &protocol,
2570            &state,
2571            &RenderOptions::new("index.html", "/"),
2572            &mut writer,
2573        )
2574        .unwrap();
2575        assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
2576    }
2577
2578    // ── Simple attribute rendering tests ──────────────────────────────
2579
2580    #[test]
2581    fn test_attribute_with_value() {
2582        let mut fragments = HashMap::new();
2583        fragments.insert(
2584            "index.html".to_string(),
2585            FragmentList {
2586                fragments: vec![
2587                    WebUIFragment::raw("<input"),
2588                    WebUIFragment::attribute("value", "inputValue"),
2589                    WebUIFragment::raw(">"),
2590                ],
2591            },
2592        );
2593        let protocol = WebUIProtocol::new(fragments);
2594        let state = test_json!({"inputValue": "Hello"});
2595        let mut writer = TestWriter::new();
2596        handle(
2597            &protocol,
2598            &state,
2599            &RenderOptions::new("index.html", "/"),
2600            &mut writer,
2601        )
2602        .unwrap();
2603        assert_eq!(writer.get_content(), "<input value=\"Hello\">");
2604    }
2605
2606    #[test]
2607    fn test_attribute_with_falsy_numeric() {
2608        let mut fragments = HashMap::new();
2609        fragments.insert(
2610            "index.html".to_string(),
2611            FragmentList {
2612                fragments: vec![
2613                    WebUIFragment::raw("<div name=\"test\""),
2614                    WebUIFragment::attribute("handle", "number"),
2615                    WebUIFragment::raw("></div>"),
2616                ],
2617            },
2618        );
2619        let protocol = WebUIProtocol::new(fragments);
2620        let state = test_json!({"number": 0});
2621        let mut writer = TestWriter::new();
2622        handle(
2623            &protocol,
2624            &state,
2625            &RenderOptions::new("index.html", "/"),
2626            &mut writer,
2627        )
2628        .unwrap();
2629        assert_eq!(
2630            writer.get_content(),
2631            "<div name=\"test\" handle=\"0\"></div>"
2632        );
2633    }
2634
2635    // ── Dynamic attribute escaping for non-string JSON types ─────────
2636
2637    #[test]
2638    fn test_attribute_array_value_is_escaped() {
2639        let mut fragments = HashMap::new();
2640        fragments.insert(
2641            "index.html".to_string(),
2642            FragmentList {
2643                fragments: vec![
2644                    WebUIFragment::raw("<a"),
2645                    WebUIFragment::attribute("href", "value"),
2646                    WebUIFragment::raw(">demo</a>"),
2647                ],
2648            },
2649        );
2650        let protocol = WebUIProtocol::new(fragments);
2651        let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
2652        let mut writer = TestWriter::new();
2653        handle(
2654            &protocol,
2655            &state,
2656            &RenderOptions::new("index.html", "/"),
2657            &mut writer,
2658        )
2659        .unwrap();
2660        let content = writer.get_content();
2661        // All inner double quotes must be entity-escaped so that the
2662        // browser never sees a second attribute boundary.
2663        assert!(
2664            content.contains("&quot;"),
2665            "Double quotes inside attribute value must be escaped: {content}"
2666        );
2667        // The href attribute value must be a single contiguous quoted
2668        // string — no extra attributes should appear.
2669        assert_eq!(
2670            content.matches("=\"").count(),
2671            1,
2672            "Only one attribute assignment expected: {content}"
2673        );
2674    }
2675
2676    #[test]
2677    fn test_attribute_object_value_is_escaped() {
2678        let mut fragments = HashMap::new();
2679        fragments.insert(
2680            "index.html".to_string(),
2681            FragmentList {
2682                fragments: vec![
2683                    WebUIFragment::raw("<div"),
2684                    WebUIFragment::attribute("data-cfg", "cfg"),
2685                    WebUIFragment::raw("></div>"),
2686                ],
2687            },
2688        );
2689        let protocol = WebUIProtocol::new(fragments);
2690        let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
2691        let mut writer = TestWriter::new();
2692        handle(
2693            &protocol,
2694            &state,
2695            &RenderOptions::new("index.html", "/"),
2696            &mut writer,
2697        )
2698        .unwrap();
2699        let content = writer.get_content();
2700        assert!(
2701            content.contains("&quot;"),
2702            "Double quotes inside attribute value must be escaped: {content}"
2703        );
2704        assert_eq!(
2705            content.matches("=\"").count(),
2706            1,
2707            "Only one attribute assignment expected: {content}"
2708        );
2709    }
2710
2711    // ── Template attribute rendering tests ────────────────────────────
2712
2713    #[test]
2714    fn test_mixed_attribute_template() {
2715        let mut fragments = HashMap::new();
2716        fragments.insert(
2717            "index.html".to_string(),
2718            FragmentList {
2719                fragments: vec![
2720                    WebUIFragment::raw("<input"),
2721                    WebUIFragment::attribute_template("value", "attr-1"),
2722                    WebUIFragment::raw(">"),
2723                ],
2724            },
2725        );
2726        fragments.insert(
2727            "attr-1".to_string(),
2728            FragmentList {
2729                fragments: vec![
2730                    WebUIFragment::raw("hello "),
2731                    WebUIFragment::signal("item", false),
2732                ],
2733            },
2734        );
2735        let protocol = WebUIProtocol::new(fragments);
2736        let state = test_json!({"item": "world"});
2737        let mut writer = TestWriter::new();
2738        handle(
2739            &protocol,
2740            &state,
2741            &RenderOptions::new("index.html", "/"),
2742            &mut writer,
2743        )
2744        .unwrap();
2745        assert_eq!(writer.get_content(), "<input value=\"hello world\">");
2746    }
2747
2748    // ── Raw signal rendering test ─────────────────────────────────────
2749
2750    #[test]
2751    fn test_raw_signal_not_escaped() {
2752        let mut fragments = HashMap::new();
2753        fragments.insert(
2754            "index.html".to_string(),
2755            FragmentList {
2756                fragments: vec![
2757                    WebUIFragment::signal("html", false),
2758                    WebUIFragment::signal("html", true),
2759                ],
2760            },
2761        );
2762        let protocol = WebUIProtocol::new(fragments);
2763        let state = test_json!({"html": "<strong>hi</strong>"});
2764        let mut writer = TestWriter::new();
2765        handle(
2766            &protocol,
2767            &state,
2768            &RenderOptions::new("index.html", "/"),
2769            &mut writer,
2770        )
2771        .unwrap();
2772        assert_eq!(
2773            writer.get_content(),
2774            "&lt;strong&gt;hi&lt;&#x2F;strong&gt;<strong>hi</strong>"
2775        );
2776    }
2777
2778    // ── Nested for loop tests ─────────────────────────────────────────
2779
2780    #[test]
2781    fn test_nested_for_loop() {
2782        let mut fragments = HashMap::new();
2783        fragments.insert(
2784            "index.html".to_string(),
2785            FragmentList {
2786                fragments: vec![
2787                    WebUIFragment::raw("<div>"),
2788                    WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
2789                    WebUIFragment::raw("</div>"),
2790                ],
2791            },
2792        );
2793        fragments.insert(
2794            "outer".to_string(),
2795            FragmentList {
2796                fragments: vec![
2797                    WebUIFragment::raw("<div>"),
2798                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
2799                    WebUIFragment::raw("</div>"),
2800                ],
2801            },
2802        );
2803        fragments.insert(
2804            "inner".to_string(),
2805            FragmentList {
2806                fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
2807            },
2808        );
2809        let protocol = WebUIProtocol::new(fragments);
2810        let state = test_json!({
2811            "outerItems": [
2812                {"innerItems": [{"name": "A"}, {"name": "B"}]},
2813                {"innerItems": [{"name": "C"}]}
2814            ]
2815        });
2816        let mut writer = TestWriter::new();
2817        handle(
2818            &protocol,
2819            &state,
2820            &RenderOptions::new("index.html", "/"),
2821            &mut writer,
2822        )
2823        .unwrap();
2824        assert_eq!(
2825            writer.get_content(),
2826            "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
2827        );
2828    }
2829
2830    #[test]
2831    fn test_nested_for_with_signals() {
2832        let mut fragments = HashMap::new();
2833        fragments.insert(
2834            "index.html".to_string(),
2835            FragmentList {
2836                fragments: vec![
2837                    WebUIFragment::raw("<div>"),
2838                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
2839                    WebUIFragment::raw("</div>"),
2840                ],
2841            },
2842        );
2843        fragments.insert(
2844            "outerTemplate".to_string(),
2845            FragmentList {
2846                fragments: vec![
2847                    WebUIFragment::raw("<div>"),
2848                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
2849                    WebUIFragment::raw("</div>"),
2850                ],
2851            },
2852        );
2853        fragments.insert(
2854            "innerTemplate".to_string(),
2855            FragmentList {
2856                fragments: vec![
2857                    WebUIFragment::raw("<span>"),
2858                    WebUIFragment::signal("innerItem.name", false),
2859                    WebUIFragment::raw("</span>"),
2860                ],
2861            },
2862        );
2863        let protocol = WebUIProtocol::new(fragments);
2864        let state = test_json!({
2865            "outerItems": [
2866                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
2867                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
2868            ]
2869        });
2870        let mut writer = TestWriter::new();
2871        handle(
2872            &protocol,
2873            &state,
2874            &RenderOptions::new("index.html", "/"),
2875            &mut writer,
2876        )
2877        .unwrap();
2878        assert_eq!(
2879            writer.get_content(),
2880            "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
2881        );
2882    }
2883
2884    #[test]
2885    fn test_nested_for_with_global_state() {
2886        let mut fragments = HashMap::new();
2887        fragments.insert(
2888            "index.html".to_string(),
2889            FragmentList {
2890                fragments: vec![
2891                    WebUIFragment::raw("<div>"),
2892                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
2893                    WebUIFragment::raw("</div>"),
2894                ],
2895            },
2896        );
2897        fragments.insert(
2898            "outerTemplate".to_string(),
2899            FragmentList {
2900                fragments: vec![
2901                    WebUIFragment::raw("<div>"),
2902                    WebUIFragment::signal("globalOuter", false),
2903                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
2904                    WebUIFragment::raw("</div>"),
2905                ],
2906            },
2907        );
2908        fragments.insert(
2909            "innerTemplate".to_string(),
2910            FragmentList {
2911                fragments: vec![
2912                    WebUIFragment::raw("<span>"),
2913                    WebUIFragment::signal("innerItem.name", false),
2914                    WebUIFragment::signal("globalInner", false),
2915                    WebUIFragment::raw("</span>"),
2916                ],
2917            },
2918        );
2919        let protocol = WebUIProtocol::new(fragments);
2920        let state = test_json!({
2921            "globalOuter": "GO",
2922            "globalInner": "GI",
2923            "outerItems": [
2924                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
2925                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
2926            ]
2927        });
2928        let mut writer = TestWriter::new();
2929        handle(
2930            &protocol,
2931            &state,
2932            &RenderOptions::new("index.html", "/"),
2933            &mut writer,
2934        )
2935        .unwrap();
2936        assert_eq!(
2937            writer.get_content(),
2938            "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
2939        );
2940    }
2941
2942    // ── For + If state scoping tests ──────────────────────────────────
2943
2944    #[test]
2945    fn test_if_in_for_uses_local_state() {
2946        let mut fragments = HashMap::new();
2947        fragments.insert(
2948            "index.html".to_string(),
2949            FragmentList {
2950                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
2951            },
2952        );
2953        fragments.insert(
2954            "item-tpl".to_string(),
2955            FragmentList {
2956                fragments: vec![WebUIFragment::if_cond(
2957                    ConditionExpr::identifier("item.visible"),
2958                    "visible-tpl",
2959                )],
2960            },
2961        );
2962        fragments.insert(
2963            "visible-tpl".to_string(),
2964            FragmentList {
2965                fragments: vec![WebUIFragment::signal("item.name", false)],
2966            },
2967        );
2968        let protocol = WebUIProtocol::new(fragments);
2969        let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
2970        let mut writer = TestWriter::new();
2971        handle(
2972            &protocol,
2973            &state,
2974            &RenderOptions::new("index.html", "/"),
2975            &mut writer,
2976        )
2977        .unwrap();
2978        assert_eq!(writer.get_content(), "Show");
2979    }
2980
2981    #[test]
2982    fn test_for_if_local_overrides_global() {
2983        let mut fragments = HashMap::new();
2984        fragments.insert(
2985            "index.html".to_string(),
2986            FragmentList {
2987                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
2988            },
2989        );
2990        fragments.insert(
2991            "item-tpl".to_string(),
2992            FragmentList {
2993                fragments: vec![WebUIFragment::if_cond(
2994                    ConditionExpr::identifier("item.flag"),
2995                    "show-tpl",
2996                )],
2997            },
2998        );
2999        fragments.insert(
3000            "show-tpl".to_string(),
3001            FragmentList {
3002                fragments: vec![WebUIFragment::raw("yes")],
3003            },
3004        );
3005        let protocol = WebUIProtocol::new(fragments);
3006        // Global flag is true, but local item.flag is false for second item
3007        let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
3008        let mut writer = TestWriter::new();
3009        handle(
3010            &protocol,
3011            &state,
3012            &RenderOptions::new("index.html", "/"),
3013            &mut writer,
3014        )
3015        .unwrap();
3016        assert_eq!(writer.get_content(), "yes");
3017    }
3018
3019    // ── Component attribute state tests ───────────────────────────────
3020
3021    #[test]
3022    fn test_component_attr_state_simple() {
3023        let mut fragments = HashMap::new();
3024        fragments.insert(
3025            "index.html".to_string(),
3026            FragmentList {
3027                fragments: vec![
3028                    WebUIFragment::raw("<my-comp"),
3029                    WebUIFragment {
3030                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3031                            WebUIFragmentAttribute {
3032                                name: "title".into(),
3033                                value: "Attribute Title".into(),
3034                                attr_start: true,
3035                                raw_value: true,
3036                                ..Default::default()
3037                            },
3038                        )),
3039                    },
3040                    WebUIFragment::raw(">"),
3041                    WebUIFragment::component("my-comp"),
3042                    WebUIFragment::raw("</my-comp>"),
3043                ],
3044            },
3045        );
3046        fragments.insert(
3047            "my-comp".to_string(),
3048            FragmentList {
3049                fragments: vec![
3050                    WebUIFragment::raw("<span>"),
3051                    WebUIFragment::signal("title", false),
3052                    WebUIFragment::raw("</span>"),
3053                ],
3054            },
3055        );
3056        let protocol = WebUIProtocol::new(fragments);
3057        let state = test_json!({"title": "Global Title"});
3058        let mut writer = TestWriter::new();
3059        handle(
3060            &protocol,
3061            &state,
3062            &RenderOptions::new("index.html", "/"),
3063            &mut writer,
3064        )
3065        .unwrap();
3066        assert_eq!(
3067            writer.get_content(),
3068            "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
3069        );
3070    }
3071
3072    #[test]
3073    fn test_component_attr_state_template() {
3074        let mut fragments = HashMap::new();
3075        fragments.insert(
3076            "index.html".to_string(),
3077            FragmentList {
3078                fragments: vec![
3079                    WebUIFragment::raw("<my-comp"),
3080                    WebUIFragment {
3081                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3082                            WebUIFragmentAttribute {
3083                                name: "title".into(),
3084                                template: "title-attr".into(),
3085                                attr_start: true,
3086                                ..Default::default()
3087                            },
3088                        )),
3089                    },
3090                    WebUIFragment::raw(">"),
3091                    WebUIFragment::component("my-comp"),
3092                    WebUIFragment::raw("</my-comp>"),
3093                ],
3094            },
3095        );
3096        fragments.insert(
3097            "title-attr".to_string(),
3098            FragmentList {
3099                fragments: vec![
3100                    WebUIFragment::raw("hello "),
3101                    WebUIFragment::signal("item", false),
3102                ],
3103            },
3104        );
3105        fragments.insert(
3106            "my-comp".to_string(),
3107            FragmentList {
3108                fragments: vec![
3109                    WebUIFragment::raw("<span>"),
3110                    WebUIFragment::signal("title", false),
3111                    WebUIFragment::raw("</span>"),
3112                ],
3113            },
3114        );
3115        let protocol = WebUIProtocol::new(fragments);
3116        let state = test_json!({"item": "<world>"});
3117        let mut writer = TestWriter::new();
3118        handle(
3119            &protocol,
3120            &state,
3121            &RenderOptions::new("index.html", "/"),
3122            &mut writer,
3123        )
3124        .unwrap();
3125        assert_eq!(
3126            writer.get_content(),
3127            "<my-comp title=\"hello &lt;world&gt;\"><span>hello &lt;world&gt;</span></my-comp>"
3128        );
3129    }
3130
3131    #[test]
3132    fn test_component_attr_camel_case() {
3133        let mut fragments = HashMap::new();
3134        fragments.insert(
3135            "index.html".to_string(),
3136            FragmentList {
3137                fragments: vec![
3138                    WebUIFragment::raw("<my-comp"),
3139                    WebUIFragment {
3140                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3141                            WebUIFragmentAttribute {
3142                                name: "data-title".into(),
3143                                template: "dt-attr".into(),
3144                                attr_start: true,
3145                                ..Default::default()
3146                            },
3147                        )),
3148                    },
3149                    WebUIFragment::raw(">"),
3150                    WebUIFragment::component("my-comp"),
3151                    WebUIFragment::raw("</my-comp>"),
3152                ],
3153            },
3154        );
3155        fragments.insert(
3156            "dt-attr".to_string(),
3157            FragmentList {
3158                fragments: vec![
3159                    WebUIFragment::raw("prefix "),
3160                    WebUIFragment::signal("item", false),
3161                ],
3162            },
3163        );
3164        fragments.insert(
3165            "my-comp".to_string(),
3166            FragmentList {
3167                fragments: vec![
3168                    WebUIFragment::raw("<span>"),
3169                    WebUIFragment::signal("dataTitle", false),
3170                    WebUIFragment::raw("</span>"),
3171                ],
3172            },
3173        );
3174        let protocol = WebUIProtocol::new(fragments);
3175        let state = test_json!({"item": "a&b"});
3176        let mut writer = TestWriter::new();
3177        handle(
3178            &protocol,
3179            &state,
3180            &RenderOptions::new("index.html", "/"),
3181            &mut writer,
3182        )
3183        .unwrap();
3184        assert_eq!(
3185            writer.get_content(),
3186            "<my-comp data-title=\"prefix a&amp;b\"><span>prefix a&amp;b</span></my-comp>"
3187        );
3188    }
3189
3190    #[test]
3191    fn test_component_complex_attr() {
3192        let mut fragments = HashMap::new();
3193        fragments.insert(
3194            "index.html".to_string(),
3195            FragmentList {
3196                fragments: vec![
3197                    WebUIFragment::raw("<my-comp"),
3198                    WebUIFragment {
3199                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3200                            WebUIFragmentAttribute {
3201                                name: ":item".into(),
3202                                value: "complexItem".into(),
3203                                attr_start: true,
3204                                complex: true,
3205                                ..Default::default()
3206                            },
3207                        )),
3208                    },
3209                    WebUIFragment::raw(">"),
3210                    WebUIFragment::component("my-comp"),
3211                    WebUIFragment::raw("</my-comp>"),
3212                ],
3213            },
3214        );
3215        fragments.insert(
3216            "my-comp".to_string(),
3217            FragmentList {
3218                fragments: vec![
3219                    WebUIFragment::raw("<span>"),
3220                    WebUIFragment::signal("item.foo", false),
3221                    WebUIFragment::raw("</span><p>"),
3222                    WebUIFragment::signal("item.bar", false),
3223                    WebUIFragment::raw("</p>"),
3224                ],
3225            },
3226        );
3227        let protocol = WebUIProtocol::new(fragments);
3228        let state = test_json!({"complexItem": {"foo": 1, "bar": "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><span>1</span><p>true</p></my-comp>"
3240        );
3241    }
3242
3243    #[test]
3244    fn test_component_no_parent_pollution() {
3245        let mut fragments = HashMap::new();
3246        fragments.insert(
3247            "index.html".to_string(),
3248            FragmentList {
3249                fragments: vec![
3250                    WebUIFragment::raw("<parent"),
3251                    WebUIFragment {
3252                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3253                            WebUIFragmentAttribute {
3254                                name: "var".into(),
3255                                value: "var".into(),
3256                                attr_start: true,
3257                                ..Default::default()
3258                            },
3259                        )),
3260                    },
3261                    WebUIFragment::raw(">"),
3262                    WebUIFragment::component("parent"),
3263                    WebUIFragment::raw("</parent>"),
3264                ],
3265            },
3266        );
3267        fragments.insert(
3268            "parent".to_string(),
3269            FragmentList {
3270                fragments: vec![
3271                    WebUIFragment::raw("Before: "),
3272                    WebUIFragment::signal("var", false),
3273                    WebUIFragment::raw("<child foo"),
3274                    WebUIFragment {
3275                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3276                            WebUIFragmentAttribute {
3277                                name: "var".into(),
3278                                value: "replaced".into(),
3279                                raw_value: true,
3280                                ..Default::default()
3281                            },
3282                        )),
3283                    },
3284                    WebUIFragment::raw(">"),
3285                    WebUIFragment::component("child"),
3286                    WebUIFragment::raw("Label</child>After: "),
3287                    WebUIFragment::signal("var", false),
3288                ],
3289            },
3290        );
3291        fragments.insert("child".to_string(), FragmentList { fragments: vec![] });
3292        let protocol = WebUIProtocol::new(fragments);
3293        let state = test_json!({"var": "original"});
3294        let mut writer = TestWriter::new();
3295        handle(
3296            &protocol,
3297            &state,
3298            &RenderOptions::new("index.html", "/"),
3299            &mut writer,
3300        )
3301        .unwrap();
3302        assert_eq!(
3303            writer.get_content(),
3304            "<parent var=\"original\">Before: original<child foo var=\"replaced\">Label</child>After: original</parent>"
3305        );
3306    }
3307
3308    #[test]
3309    fn test_component_boolean_attr_state() {
3310        let mut fragments = HashMap::new();
3311        fragments.insert(
3312            "index.html".to_string(),
3313            FragmentList {
3314                fragments: vec![
3315                    WebUIFragment::raw("<my-comp"),
3316                    WebUIFragment {
3317                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3318                            WebUIFragmentAttribute {
3319                                name: "disabled".into(),
3320                                attr_start: true,
3321                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
3322                                ..Default::default()
3323                            },
3324                        )),
3325                    },
3326                    WebUIFragment::raw(">"),
3327                    WebUIFragment::component("my-comp"),
3328                    WebUIFragment::raw("</my-comp>"),
3329                ],
3330            },
3331        );
3332        fragments.insert(
3333            "my-comp".to_string(),
3334            FragmentList {
3335                fragments: vec![WebUIFragment::if_cond(
3336                    ConditionExpr::identifier("disabled"),
3337                    "show",
3338                )],
3339            },
3340        );
3341        fragments.insert(
3342            "show".to_string(),
3343            FragmentList {
3344                fragments: vec![WebUIFragment::raw("disabled!")],
3345            },
3346        );
3347        let protocol = WebUIProtocol::new(fragments);
3348        let state = test_json!({"isDisabled": true});
3349        let mut writer = TestWriter::new();
3350        handle(
3351            &protocol,
3352            &state,
3353            &RenderOptions::new("index.html", "/"),
3354            &mut writer,
3355        )
3356        .unwrap();
3357        assert_eq!(
3358            writer.get_content(),
3359            "<my-comp disabled>disabled!</my-comp>"
3360        );
3361    }
3362
3363    // ===== HTML Escape Tests (ported from utils.test.js escapeHtml) =====
3364
3365    /// Helper: render a signal value through the handler and return the escaped output.
3366    fn render_signal(value: &str) -> String {
3367        let mut fragments = HashMap::new();
3368        fragments.insert(
3369            "index.html".to_string(),
3370            FragmentList {
3371                fragments: vec![WebUIFragment::signal("v", false)],
3372            },
3373        );
3374        let protocol = WebUIProtocol::new(fragments);
3375        let state = test_json!({"v": value});
3376        let mut writer = TestWriter::new();
3377        handle(
3378            &protocol,
3379            &state,
3380            &RenderOptions::new("index.html", "/"),
3381            &mut writer,
3382        )
3383        .unwrap();
3384        writer.get_content()
3385    }
3386
3387    #[test]
3388    fn test_escape_ampersand() {
3389        assert_eq!(render_signal("&"), "&amp;");
3390    }
3391
3392    #[test]
3393    fn test_escape_less_than() {
3394        assert_eq!(render_signal("<"), "&lt;");
3395    }
3396
3397    #[test]
3398    fn test_escape_greater_than() {
3399        assert_eq!(render_signal(">"), "&gt;");
3400    }
3401
3402    #[test]
3403    fn test_escape_double_quote() {
3404        assert_eq!(render_signal("\""), "&quot;");
3405    }
3406
3407    #[test]
3408    fn test_escape_single_quote() {
3409        // encode_safe escapes ' as &#x27;
3410        let result = render_signal("'");
3411        assert_eq!(
3412            result, "&#x27;",
3413            "Expected &#x27; for single quote, got: {result}"
3414        );
3415    }
3416
3417    #[test]
3418    fn test_escape_multiple_special_chars() {
3419        let result = render_signal("<script>alert('xss');</script>");
3420        assert!(
3421            result.contains("&lt;") && result.contains("&gt;"),
3422            "Expected escaped HTML, got: {}",
3423            result
3424        );
3425        assert!(
3426            !result.contains("<script>"),
3427            "Should not contain raw <script> tag"
3428        );
3429    }
3430
3431    #[test]
3432    fn test_escape_no_special_chars() {
3433        assert_eq!(render_signal("Hello World"), "Hello World");
3434    }
3435
3436    #[test]
3437    fn test_escape_empty_string() {
3438        assert_eq!(render_signal(""), "");
3439    }
3440
3441    #[test]
3442    fn test_escape_special_at_beginning() {
3443        let result = render_signal("<Hello");
3444        assert!(
3445            result.starts_with("&lt;"),
3446            "Expected &lt; at start, got: {}",
3447            result
3448        );
3449    }
3450
3451    #[test]
3452    fn test_escape_special_at_end() {
3453        let result = render_signal("Hello>");
3454        assert!(
3455            result.ends_with("&gt;"),
3456            "Expected &gt; at end, got: {}",
3457            result
3458        );
3459    }
3460
3461    #[test]
3462    fn test_escape_special_in_middle() {
3463        let result = render_signal("Hel&lo");
3464        assert!(
3465            result.contains("&amp;"),
3466            "Expected &amp; in middle, got: {}",
3467            result
3468        );
3469    }
3470
3471    // ── GROUP 5: Boolean Attribute Edge Cases ─────────────────────────
3472
3473    #[test]
3474    fn test_boolean_attr_truthy_values() {
3475        // checked: 1
3476        {
3477            let mut fragments = HashMap::new();
3478            fragments.insert(
3479                "index.html".to_string(),
3480                FragmentList {
3481                    fragments: vec![
3482                        WebUIFragment::raw("<input"),
3483                        WebUIFragment::attribute_boolean(
3484                            "checked",
3485                            ConditionExpr::identifier("checked"),
3486                        ),
3487                        WebUIFragment::raw(">"),
3488                    ],
3489                },
3490            );
3491            let protocol = WebUIProtocol::new(fragments);
3492            let state = test_json!({"checked": 1});
3493            let mut writer = TestWriter::new();
3494            handle(
3495                &protocol,
3496                &state,
3497                &RenderOptions::new("index.html", "/"),
3498                &mut writer,
3499            )
3500            .unwrap();
3501            assert_eq!(writer.get_content(), "<input checked>");
3502        }
3503        // checked: "yes"
3504        {
3505            let mut fragments = HashMap::new();
3506            fragments.insert(
3507                "index.html".to_string(),
3508                FragmentList {
3509                    fragments: vec![
3510                        WebUIFragment::raw("<input"),
3511                        WebUIFragment::attribute_boolean(
3512                            "checked",
3513                            ConditionExpr::identifier("checked"),
3514                        ),
3515                        WebUIFragment::raw(">"),
3516                    ],
3517                },
3518            );
3519            let protocol = WebUIProtocol::new(fragments);
3520            let state = test_json!({"checked": "yes"});
3521            let mut writer = TestWriter::new();
3522            handle(
3523                &protocol,
3524                &state,
3525                &RenderOptions::new("index.html", "/"),
3526                &mut writer,
3527            )
3528            .unwrap();
3529            assert_eq!(writer.get_content(), "<input checked>");
3530        }
3531        // checked: {} (empty object is truthy)
3532        {
3533            let mut fragments = HashMap::new();
3534            fragments.insert(
3535                "index.html".to_string(),
3536                FragmentList {
3537                    fragments: vec![
3538                        WebUIFragment::raw("<input"),
3539                        WebUIFragment::attribute_boolean(
3540                            "checked",
3541                            ConditionExpr::identifier("checked"),
3542                        ),
3543                        WebUIFragment::raw(">"),
3544                    ],
3545                },
3546            );
3547            let protocol = WebUIProtocol::new(fragments);
3548            let state = test_json!({"checked": {}});
3549            let mut writer = TestWriter::new();
3550            handle(
3551                &protocol,
3552                &state,
3553                &RenderOptions::new("index.html", "/"),
3554                &mut writer,
3555            )
3556            .unwrap();
3557            // Empty object is falsy in this expression evaluator
3558            assert_eq!(writer.get_content(), "<input>");
3559        }
3560        // checked: "false" (string "false" is truthy)
3561        {
3562            let mut fragments = HashMap::new();
3563            fragments.insert(
3564                "index.html".to_string(),
3565                FragmentList {
3566                    fragments: vec![
3567                        WebUIFragment::raw("<input"),
3568                        WebUIFragment::attribute_boolean(
3569                            "checked",
3570                            ConditionExpr::identifier("checked"),
3571                        ),
3572                        WebUIFragment::raw(">"),
3573                    ],
3574                },
3575            );
3576            let protocol = WebUIProtocol::new(fragments);
3577            let state = test_json!({"checked": "false"});
3578            let mut writer = TestWriter::new();
3579            handle(
3580                &protocol,
3581                &state,
3582                &RenderOptions::new("index.html", "/"),
3583                &mut writer,
3584            )
3585            .unwrap();
3586            assert_eq!(writer.get_content(), "<input checked>");
3587        }
3588    }
3589
3590    #[test]
3591    fn test_boolean_attr_falsy_values() {
3592        // checked: 0
3593        {
3594            let mut fragments = HashMap::new();
3595            fragments.insert(
3596                "index.html".to_string(),
3597                FragmentList {
3598                    fragments: vec![
3599                        WebUIFragment::raw("<input"),
3600                        WebUIFragment::attribute_boolean(
3601                            "checked",
3602                            ConditionExpr::identifier("checked"),
3603                        ),
3604                        WebUIFragment::raw(">"),
3605                    ],
3606                },
3607            );
3608            let protocol = WebUIProtocol::new(fragments);
3609            let state = test_json!({"checked": 0});
3610            let mut writer = TestWriter::new();
3611            handle(
3612                &protocol,
3613                &state,
3614                &RenderOptions::new("index.html", "/"),
3615                &mut writer,
3616            )
3617            .unwrap();
3618            assert_eq!(writer.get_content(), "<input>");
3619        }
3620        // checked: ""
3621        {
3622            let mut fragments = HashMap::new();
3623            fragments.insert(
3624                "index.html".to_string(),
3625                FragmentList {
3626                    fragments: vec![
3627                        WebUIFragment::raw("<input"),
3628                        WebUIFragment::attribute_boolean(
3629                            "checked",
3630                            ConditionExpr::identifier("checked"),
3631                        ),
3632                        WebUIFragment::raw(">"),
3633                    ],
3634                },
3635            );
3636            let protocol = WebUIProtocol::new(fragments);
3637            let state = test_json!({"checked": ""});
3638            let mut writer = TestWriter::new();
3639            handle(
3640                &protocol,
3641                &state,
3642                &RenderOptions::new("index.html", "/"),
3643                &mut writer,
3644            )
3645            .unwrap();
3646            assert_eq!(writer.get_content(), "<input>");
3647        }
3648        // checked: false
3649        {
3650            let mut fragments = HashMap::new();
3651            fragments.insert(
3652                "index.html".to_string(),
3653                FragmentList {
3654                    fragments: vec![
3655                        WebUIFragment::raw("<input"),
3656                        WebUIFragment::attribute_boolean(
3657                            "checked",
3658                            ConditionExpr::identifier("checked"),
3659                        ),
3660                        WebUIFragment::raw(">"),
3661                    ],
3662                },
3663            );
3664            let protocol = WebUIProtocol::new(fragments);
3665            let state = test_json!({"checked": false});
3666            let mut writer = TestWriter::new();
3667            handle(
3668                &protocol,
3669                &state,
3670                &RenderOptions::new("index.html", "/"),
3671                &mut writer,
3672            )
3673            .unwrap();
3674            assert_eq!(writer.get_content(), "<input>");
3675        }
3676        // no checked key at all
3677        {
3678            let mut fragments = HashMap::new();
3679            fragments.insert(
3680                "index.html".to_string(),
3681                FragmentList {
3682                    fragments: vec![
3683                        WebUIFragment::raw("<input"),
3684                        WebUIFragment::attribute_boolean(
3685                            "checked",
3686                            ConditionExpr::identifier("checked"),
3687                        ),
3688                        WebUIFragment::raw(">"),
3689                    ],
3690                },
3691            );
3692            let protocol = WebUIProtocol::new(fragments);
3693            let state = test_json!({});
3694            let mut writer = TestWriter::new();
3695            handle(
3696                &protocol,
3697                &state,
3698                &RenderOptions::new("index.html", "/"),
3699                &mut writer,
3700            )
3701            .unwrap();
3702            assert_eq!(writer.get_content(), "<input>");
3703        }
3704    }
3705
3706    #[test]
3707    fn test_boolean_attr_expression_true() {
3708        let mut fragments = HashMap::new();
3709        fragments.insert(
3710            "index.html".to_string(),
3711            FragmentList {
3712                fragments: vec![
3713                    WebUIFragment::raw("<button"),
3714                    WebUIFragment::attribute_boolean(
3715                        "disabled",
3716                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
3717                    ),
3718                    WebUIFragment::raw(">Click</button>"),
3719                ],
3720            },
3721        );
3722        let protocol = WebUIProtocol::new(fragments);
3723        let state = test_json!({"itemCount": 5});
3724        let mut writer = TestWriter::new();
3725        handle(
3726            &protocol,
3727            &state,
3728            &RenderOptions::new("index.html", "/"),
3729            &mut writer,
3730        )
3731        .unwrap();
3732        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
3733    }
3734
3735    #[test]
3736    fn test_boolean_attr_expression_false() {
3737        let mut fragments = HashMap::new();
3738        fragments.insert(
3739            "index.html".to_string(),
3740            FragmentList {
3741                fragments: vec![
3742                    WebUIFragment::raw("<button"),
3743                    WebUIFragment::attribute_boolean(
3744                        "disabled",
3745                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
3746                    ),
3747                    WebUIFragment::raw(">Click</button>"),
3748                ],
3749            },
3750        );
3751        let protocol = WebUIProtocol::new(fragments);
3752        let state = test_json!({"itemCount": 3});
3753        let mut writer = TestWriter::new();
3754        handle(
3755            &protocol,
3756            &state,
3757            &RenderOptions::new("index.html", "/"),
3758            &mut writer,
3759        )
3760        .unwrap();
3761        assert_eq!(writer.get_content(), "<button>Click</button>");
3762    }
3763
3764    // ── GROUP 6: Mixed Attributes ─────────────────────────────────────
3765
3766    #[test]
3767    fn test_nested_component_attr_capture() {
3768        let mut fragments = HashMap::new();
3769        fragments.insert(
3770            "index.html".to_string(),
3771            FragmentList {
3772                fragments: vec![
3773                    WebUIFragment::raw("<parent-component"),
3774                    WebUIFragment {
3775                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3776                            WebUIFragmentAttribute {
3777                                name: "title".into(),
3778                                template: "parent-title".into(),
3779                                attr_start: true,
3780                                ..Default::default()
3781                            },
3782                        )),
3783                    },
3784                    WebUIFragment::raw(">"),
3785                    WebUIFragment::component("parent-component"),
3786                    WebUIFragment::raw("</parent-component>"),
3787                ],
3788            },
3789        );
3790        fragments.insert(
3791            "parent-title".to_string(),
3792            FragmentList {
3793                fragments: vec![
3794                    WebUIFragment::raw("Hello "),
3795                    WebUIFragment::signal("who", false),
3796                ],
3797            },
3798        );
3799        fragments.insert(
3800            "parent-component".to_string(),
3801            FragmentList {
3802                fragments: vec![
3803                    WebUIFragment::raw("<child-component"),
3804                    WebUIFragment {
3805                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3806                            WebUIFragmentAttribute {
3807                                name: "title".into(),
3808                                template: "child-title".into(),
3809                                attr_start: true,
3810                                ..Default::default()
3811                            },
3812                        )),
3813                    },
3814                    WebUIFragment::raw(">"),
3815                    WebUIFragment::component("child-component"),
3816                    WebUIFragment::raw("</child-component>"),
3817                ],
3818            },
3819        );
3820        fragments.insert(
3821            "child-title".to_string(),
3822            FragmentList {
3823                fragments: vec![
3824                    WebUIFragment::raw("Child of "),
3825                    WebUIFragment::signal("title", false),
3826                ],
3827            },
3828        );
3829        fragments.insert(
3830            "child-component".to_string(),
3831            FragmentList {
3832                fragments: vec![
3833                    WebUIFragment::raw("<span>"),
3834                    WebUIFragment::signal("title", false),
3835                    WebUIFragment::raw("</span>"),
3836                ],
3837            },
3838        );
3839        let protocol = WebUIProtocol::new(fragments);
3840        let state = test_json!({"who": "<world>"});
3841        let mut writer = TestWriter::new();
3842        handle(
3843            &protocol,
3844            &state,
3845            &RenderOptions::new("index.html", "/"),
3846            &mut writer,
3847        )
3848        .unwrap();
3849        assert_eq!(
3850            writer.get_content(),
3851            "<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>"
3852        );
3853    }
3854
3855    #[test]
3856    fn test_grandchild_attr_propagation() {
3857        let mut fragments = HashMap::new();
3858        fragments.insert(
3859            "index.html".to_string(),
3860            FragmentList {
3861                fragments: vec![
3862                    WebUIFragment::raw("<parent-component"),
3863                    WebUIFragment {
3864                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3865                            WebUIFragmentAttribute {
3866                                name: "title".into(),
3867                                template: "p-title".into(),
3868                                attr_start: true,
3869                                ..Default::default()
3870                            },
3871                        )),
3872                    },
3873                    WebUIFragment::raw(">"),
3874                    WebUIFragment::component("parent-component"),
3875                    WebUIFragment::raw("</parent-component>"),
3876                ],
3877            },
3878        );
3879        fragments.insert(
3880            "p-title".to_string(),
3881            FragmentList {
3882                fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
3883            },
3884        );
3885        fragments.insert(
3886            "parent-component".to_string(),
3887            FragmentList {
3888                fragments: vec![
3889                    WebUIFragment::raw("<child-component"),
3890                    WebUIFragment {
3891                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3892                            WebUIFragmentAttribute {
3893                                name: "title".into(),
3894                                template: "c-title".into(),
3895                                attr_start: true,
3896                                ..Default::default()
3897                            },
3898                        )),
3899                    },
3900                    WebUIFragment::raw(">"),
3901                    WebUIFragment::component("child-component"),
3902                    WebUIFragment::raw("</child-component>"),
3903                ],
3904            },
3905        );
3906        fragments.insert(
3907            "c-title".to_string(),
3908            FragmentList {
3909                fragments: vec![
3910                    WebUIFragment::raw("C("),
3911                    WebUIFragment::signal("title", false),
3912                    WebUIFragment::raw(")-"),
3913                    WebUIFragment::signal("cExtra", false),
3914                ],
3915            },
3916        );
3917        fragments.insert(
3918            "child-component".to_string(),
3919            FragmentList {
3920                fragments: vec![
3921                    WebUIFragment::raw("<grandchild-component"),
3922                    WebUIFragment {
3923                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3924                            WebUIFragmentAttribute {
3925                                name: "title".into(),
3926                                value: "title".into(),
3927                                attr_start: true,
3928                                ..Default::default()
3929                            },
3930                        )),
3931                    },
3932                    WebUIFragment::raw(">"),
3933                    WebUIFragment::component("grandchild-component"),
3934                    WebUIFragment::raw("</grandchild-component>"),
3935                ],
3936            },
3937        );
3938        fragments.insert(
3939            "grandchild-component".to_string(),
3940            FragmentList {
3941                fragments: vec![
3942                    WebUIFragment::raw("<span>"),
3943                    WebUIFragment::signal("title", false),
3944                    WebUIFragment::raw("</span>"),
3945                ],
3946            },
3947        );
3948        let protocol = WebUIProtocol::new(fragments);
3949        let state = test_json!({"p": "<p>", "cExtra": "x&y"});
3950        let mut writer = TestWriter::new();
3951        handle(
3952            &protocol,
3953            &state,
3954            &RenderOptions::new("index.html", "/"),
3955            &mut writer,
3956        )
3957        .unwrap();
3958        assert_eq!(
3959            writer.get_content(),
3960            "<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>"
3961        );
3962    }
3963
3964    #[test]
3965    fn test_for_loop_component_attr() {
3966        let mut fragments = HashMap::new();
3967        fragments.insert(
3968            "index.html".to_string(),
3969            FragmentList {
3970                fragments: vec![
3971                    WebUIFragment::raw("<parent-component"),
3972                    WebUIFragment {
3973                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3974                            WebUIFragmentAttribute {
3975                                name: "title".into(),
3976                                template: "parent-title-loop".into(),
3977                                attr_start: true,
3978                                ..Default::default()
3979                            },
3980                        )),
3981                    },
3982                    WebUIFragment::raw(">"),
3983                    WebUIFragment::component("parent-component"),
3984                    WebUIFragment::raw("</parent-component>"),
3985                ],
3986            },
3987        );
3988        fragments.insert(
3989            "parent-title-loop".to_string(),
3990            FragmentList {
3991                fragments: vec![
3992                    WebUIFragment::raw("Parent:"),
3993                    WebUIFragment::signal("who", false),
3994                ],
3995            },
3996        );
3997        fragments.insert(
3998            "parent-component".to_string(),
3999            FragmentList {
4000                fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
4001            },
4002        );
4003        fragments.insert(
4004            "child-loop".to_string(),
4005            FragmentList {
4006                fragments: vec![
4007                    WebUIFragment::raw("<child-component"),
4008                    WebUIFragment {
4009                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4010                            WebUIFragmentAttribute {
4011                                name: "title".into(),
4012                                template: "child-title-loop".into(),
4013                                attr_start: true,
4014                                ..Default::default()
4015                            },
4016                        )),
4017                    },
4018                    WebUIFragment::raw(">"),
4019                    WebUIFragment::component("child-component"),
4020                    WebUIFragment::raw("</child-component>"),
4021                ],
4022            },
4023        );
4024        fragments.insert(
4025            "child-title-loop".to_string(),
4026            FragmentList {
4027                fragments: vec![
4028                    WebUIFragment::raw("Hi "),
4029                    WebUIFragment::signal("item.name", false),
4030                    WebUIFragment::raw(" / "),
4031                    WebUIFragment::signal("title", false),
4032                ],
4033            },
4034        );
4035        fragments.insert(
4036            "child-component".to_string(),
4037            FragmentList {
4038                fragments: vec![
4039                    WebUIFragment::raw("<span>"),
4040                    WebUIFragment::signal("title", false),
4041                    WebUIFragment::raw("</span>"),
4042                ],
4043            },
4044        );
4045        let protocol = WebUIProtocol::new(fragments);
4046        let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
4047        let mut writer = TestWriter::new();
4048        handle(
4049            &protocol,
4050            &state,
4051            &RenderOptions::new("index.html", "/"),
4052            &mut writer,
4053        )
4054        .unwrap();
4055        assert_eq!(
4056            writer.get_content(),
4057            "<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>"
4058        );
4059    }
4060
4061    #[test]
4062    fn test_multiple_template_attrs() {
4063        let mut fragments = HashMap::new();
4064        fragments.insert(
4065            "index.html".to_string(),
4066            FragmentList {
4067                fragments: vec![
4068                    WebUIFragment::raw("<my-component"),
4069                    WebUIFragment {
4070                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4071                            WebUIFragmentAttribute {
4072                                name: "title".into(),
4073                                template: "attr-title".into(),
4074                                attr_start: true,
4075                                ..Default::default()
4076                            },
4077                        )),
4078                    },
4079                    WebUIFragment {
4080                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4081                            WebUIFragmentAttribute {
4082                                name: "data-title".into(),
4083                                template: "attr-data-title".into(),
4084                                attr_start: false,
4085                                ..Default::default()
4086                            },
4087                        )),
4088                    },
4089                    WebUIFragment {
4090                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4091                            WebUIFragmentAttribute {
4092                                name: "aria-label".into(),
4093                                template: "attr-aria-label".into(),
4094                                attr_start: false,
4095                                ..Default::default()
4096                            },
4097                        )),
4098                    },
4099                    WebUIFragment::raw(">"),
4100                    WebUIFragment::component("my-component"),
4101                    WebUIFragment::raw("</my-component>"),
4102                ],
4103            },
4104        );
4105        fragments.insert(
4106            "attr-title".to_string(),
4107            FragmentList {
4108                fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
4109            },
4110        );
4111        fragments.insert(
4112            "attr-data-title".to_string(),
4113            FragmentList {
4114                fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
4115            },
4116        );
4117        fragments.insert(
4118            "attr-aria-label".to_string(),
4119            FragmentList {
4120                fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
4121            },
4122        );
4123        fragments.insert(
4124            "my-component".to_string(),
4125            FragmentList {
4126                fragments: vec![
4127                    WebUIFragment::raw("<span>"),
4128                    WebUIFragment::signal("title", false),
4129                    WebUIFragment::raw("|"),
4130                    WebUIFragment::signal("dataTitle", false),
4131                    WebUIFragment::raw("|"),
4132                    WebUIFragment::signal("ariaLabel", false),
4133                    WebUIFragment::raw("</span>"),
4134                ],
4135            },
4136        );
4137        let protocol = WebUIProtocol::new(fragments);
4138        let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
4139        let mut writer = TestWriter::new();
4140        handle(
4141            &protocol,
4142            &state,
4143            &RenderOptions::new("index.html", "/"),
4144            &mut writer,
4145        )
4146        .unwrap();
4147        assert_eq!(
4148            writer.get_content(),
4149            "<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>"
4150        );
4151    }
4152
4153    #[test]
4154    fn test_attr_priority_over_global() {
4155        let mut fragments = HashMap::new();
4156        fragments.insert(
4157            "index.html".to_string(),
4158            FragmentList {
4159                fragments: vec![
4160                    WebUIFragment::raw("<my-component"),
4161                    WebUIFragment {
4162                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4163                            WebUIFragmentAttribute {
4164                                name: "title".into(),
4165                                value: "Attribute Title".into(),
4166                                raw_value: true,
4167                                attr_start: true,
4168                                ..Default::default()
4169                            },
4170                        )),
4171                    },
4172                    WebUIFragment::raw(">"),
4173                    WebUIFragment::component("my-component"),
4174                    WebUIFragment::raw("</my-component>"),
4175                ],
4176            },
4177        );
4178        fragments.insert(
4179            "my-component".to_string(),
4180            FragmentList {
4181                fragments: vec![
4182                    WebUIFragment::raw("<span>"),
4183                    WebUIFragment::signal("title", false),
4184                    WebUIFragment::raw("</span>"),
4185                ],
4186            },
4187        );
4188        let protocol = WebUIProtocol::new(fragments);
4189        let state = test_json!({"title": "Global Title"});
4190        let mut writer = TestWriter::new();
4191        handle(
4192            &protocol,
4193            &state,
4194            &RenderOptions::new("index.html", "/"),
4195            &mut writer,
4196        )
4197        .unwrap();
4198        assert_eq!(
4199            writer.get_content(),
4200            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
4201        );
4202    }
4203
4204    #[test]
4205    fn test_attr_priority_over_local_and_global() {
4206        let mut fragments = HashMap::new();
4207        fragments.insert(
4208            "index.html".to_string(),
4209            FragmentList {
4210                fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
4211            },
4212        );
4213        fragments.insert(
4214            "loop".to_string(),
4215            FragmentList {
4216                fragments: vec![
4217                    WebUIFragment::raw("<my-component"),
4218                    WebUIFragment {
4219                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4220                            WebUIFragmentAttribute {
4221                                name: "title".into(),
4222                                value: "Attribute Title".into(),
4223                                raw_value: true,
4224                                attr_start: true,
4225                                ..Default::default()
4226                            },
4227                        )),
4228                    },
4229                    WebUIFragment::raw(">"),
4230                    WebUIFragment::component("my-component"),
4231                    WebUIFragment::raw("</my-component>"),
4232                ],
4233            },
4234        );
4235        fragments.insert(
4236            "my-component".to_string(),
4237            FragmentList {
4238                fragments: vec![
4239                    WebUIFragment::raw("<span>"),
4240                    WebUIFragment::signal("title", false),
4241                    WebUIFragment::raw("</span>"),
4242                ],
4243            },
4244        );
4245        let protocol = WebUIProtocol::new(fragments);
4246        let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
4247        let mut writer = TestWriter::new();
4248        handle(
4249            &protocol,
4250            &state,
4251            &RenderOptions::new("index.html", "/"),
4252            &mut writer,
4253        )
4254        .unwrap();
4255        assert_eq!(
4256            writer.get_content(),
4257            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
4258        );
4259    }
4260
4261    #[test]
4262    fn test_boolean_attr_first_component_attr() {
4263        let mut fragments = HashMap::new();
4264        fragments.insert(
4265            "index.html".to_string(),
4266            FragmentList {
4267                fragments: vec![
4268                    WebUIFragment::raw("<my-component"),
4269                    WebUIFragment {
4270                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4271                            WebUIFragmentAttribute {
4272                                name: "disabled".into(),
4273                                attr_start: true,
4274                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4275                                ..Default::default()
4276                            },
4277                        )),
4278                    },
4279                    WebUIFragment {
4280                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4281                            WebUIFragmentAttribute {
4282                                name: "label".into(),
4283                                value: "Component Label".into(),
4284                                raw_value: true,
4285                                attr_start: false,
4286                                ..Default::default()
4287                            },
4288                        )),
4289                    },
4290                    WebUIFragment::raw(">"),
4291                    WebUIFragment::component("my-component"),
4292                    WebUIFragment::raw("</my-component>"),
4293                ],
4294            },
4295        );
4296        fragments.insert(
4297            "my-component".to_string(),
4298            FragmentList {
4299                fragments: vec![
4300                    WebUIFragment::if_cond(
4301                        ConditionExpr::identifier("disabled"),
4302                        "disabledTemplate",
4303                    ),
4304                    WebUIFragment::raw("<span>"),
4305                    WebUIFragment::signal("label", false),
4306                    WebUIFragment::raw("</span>"),
4307                ],
4308            },
4309        );
4310        fragments.insert(
4311            "disabledTemplate".to_string(),
4312            FragmentList {
4313                fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
4314            },
4315        );
4316        let protocol = WebUIProtocol::new(fragments);
4317        let state = test_json!({"isDisabled": true});
4318        let mut writer = TestWriter::new();
4319        handle(
4320            &protocol,
4321            &state,
4322            &RenderOptions::new("index.html", "/"),
4323            &mut writer,
4324        )
4325        .unwrap();
4326        assert_eq!(
4327            writer.get_content(),
4328            "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
4329        );
4330    }
4331
4332    #[test]
4333    fn test_hyphenated_attr_camelcase() {
4334        let mut fragments = HashMap::new();
4335        fragments.insert(
4336            "index.html".to_string(),
4337            FragmentList {
4338                fragments: vec![
4339                    WebUIFragment::raw("<my-component"),
4340                    WebUIFragment {
4341                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4342                            WebUIFragmentAttribute {
4343                                name: "key-hyphen".into(),
4344                                value: "Local Value".into(),
4345                                raw_value: true,
4346                                attr_start: true,
4347                                ..Default::default()
4348                            },
4349                        )),
4350                    },
4351                    WebUIFragment::raw(">"),
4352                    WebUIFragment::component("my-component"),
4353                    WebUIFragment::raw("</my-component>"),
4354                ],
4355            },
4356        );
4357        fragments.insert(
4358            "my-component".to_string(),
4359            FragmentList {
4360                fragments: vec![
4361                    WebUIFragment::raw("<span>"),
4362                    WebUIFragment::signal("keyHyphen", false),
4363                    WebUIFragment::raw("</span>"),
4364                ],
4365            },
4366        );
4367        let protocol = WebUIProtocol::new(fragments);
4368        let state = test_json!({"keyHyphen": "Global Value"});
4369        let mut writer = TestWriter::new();
4370        handle(
4371            &protocol,
4372            &state,
4373            &RenderOptions::new("index.html", "/"),
4374            &mut writer,
4375        )
4376        .unwrap();
4377        assert_eq!(
4378            writer.get_content(),
4379            "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
4380        );
4381    }
4382
4383    #[test]
4384    fn test_skipped_component_attrs() {
4385        // Skipped attributes: class, style, role, data-*, aria-*
4386        // Plus framework-specific prefixes/names that the parser marks with attr_skip.
4387        // These render on the HTML element but are NOT passed into component attribute state.
4388        let mut fragments = HashMap::new();
4389        fragments.insert(
4390            "index.html".to_string(),
4391            FragmentList {
4392                fragments: vec![
4393                    WebUIFragment::raw("<test-component"),
4394                    // Skipped: class
4395                    WebUIFragment {
4396                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4397                            WebUIFragmentAttribute {
4398                                name: "class".into(),
4399                                value: "skippedClass".into(),
4400                                attr_start: true,
4401                                attr_skip: true,
4402                                ..Default::default()
4403                            },
4404                        )),
4405                    },
4406                    // Skipped: style
4407                    WebUIFragment {
4408                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4409                            WebUIFragmentAttribute {
4410                                name: "style".into(),
4411                                value: "skippedStyle".into(),
4412                                attr_skip: true,
4413                                ..Default::default()
4414                            },
4415                        )),
4416                    },
4417                    // Skipped: role
4418                    WebUIFragment {
4419                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4420                            WebUIFragmentAttribute {
4421                                name: "role".into(),
4422                                value: "skippedRole".into(),
4423                                attr_skip: true,
4424                                ..Default::default()
4425                            },
4426                        )),
4427                    },
4428                    // Skipped: data-testid (data-* prefix)
4429                    WebUIFragment {
4430                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4431                            WebUIFragmentAttribute {
4432                                name: "data-testid".into(),
4433                                value: "skippedDataTestid".into(),
4434                                attr_skip: true,
4435                                ..Default::default()
4436                            },
4437                        )),
4438                    },
4439                    // Skipped: aria-label (aria-* prefix)
4440                    WebUIFragment {
4441                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4442                            WebUIFragmentAttribute {
4443                                name: "aria-label".into(),
4444                                value: "skippedAriaLabel".into(),
4445                                attr_skip: true,
4446                                ..Default::default()
4447                            },
4448                        )),
4449                    },
4450                    // NOT skipped: title
4451                    WebUIFragment {
4452                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4453                            WebUIFragmentAttribute {
4454                                name: "title".into(),
4455                                value: "title".into(),
4456                                ..Default::default()
4457                            },
4458                        )),
4459                    },
4460                    WebUIFragment::raw(">"),
4461                    WebUIFragment::component("test-component"),
4462                    WebUIFragment::raw("</test-component>"),
4463                ],
4464            },
4465        );
4466        fragments.insert(
4467            "test-component".to_string(),
4468            FragmentList {
4469                fragments: vec![
4470                    WebUIFragment::raw("<span>"),
4471                    WebUIFragment::signal("title", false),
4472                    WebUIFragment::raw("-"),
4473                    WebUIFragment::signal("class", false),
4474                    WebUIFragment::raw("-"),
4475                    WebUIFragment::signal("style", false),
4476                    WebUIFragment::raw("-"),
4477                    WebUIFragment::signal("role", false),
4478                    WebUIFragment::raw("-"),
4479                    WebUIFragment::signal("dataTestid", false),
4480                    WebUIFragment::raw("-"),
4481                    WebUIFragment::signal("ariaLabel", false),
4482                    WebUIFragment::raw("</span>"),
4483                ],
4484            },
4485        );
4486        let protocol = WebUIProtocol::new(fragments);
4487        let state = test_json!({
4488            "title": "Hello",
4489            "skippedClass": "my-class",
4490            "skippedStyle": "color:red",
4491            "skippedRole": "button",
4492            "skippedDataTestid": "test-id",
4493            "skippedAriaLabel": "label-text"
4494        });
4495        let mut writer = TestWriter::new();
4496        handle(
4497            &protocol,
4498            &state,
4499            &RenderOptions::new("index.html", "/"),
4500            &mut writer,
4501        )
4502        .unwrap();
4503        // Skipped attrs render on the element but their values are NOT accessible inside the component.
4504        // The component's signals for skipped attrs resolve to empty strings.
4505        // Only "title" (non-skipped) is accessible.
4506        assert_eq!(
4507            writer.get_content(),
4508            "<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>"
4509        );
4510    }
4511
4512    // ── GROUP 7: Attribute Inheritance ─────────────────────────────────
4513
4514    #[test]
4515    fn test_attr_inherit_parent_to_child() {
4516        let mut fragments = HashMap::new();
4517        fragments.insert(
4518            "index.html".to_string(),
4519            FragmentList {
4520                fragments: vec![
4521                    WebUIFragment::raw("<parent-component"),
4522                    WebUIFragment {
4523                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4524                            WebUIFragmentAttribute {
4525                                name: "title".into(),
4526                                value: "Parent Title".into(),
4527                                raw_value: true,
4528                                attr_start: true,
4529                                ..Default::default()
4530                            },
4531                        )),
4532                    },
4533                    WebUIFragment::raw(">"),
4534                    WebUIFragment::component("parent-component"),
4535                    WebUIFragment::raw("</parent-component>"),
4536                ],
4537            },
4538        );
4539        fragments.insert(
4540            "parent-component".to_string(),
4541            FragmentList {
4542                fragments: vec![
4543                    WebUIFragment::raw("<h1>"),
4544                    WebUIFragment::signal("title", false),
4545                    WebUIFragment::raw("</h1><child-component"),
4546                    WebUIFragment {
4547                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4548                            WebUIFragmentAttribute {
4549                                name: "title".into(),
4550                                value: "title".into(),
4551                                attr_start: true,
4552                                ..Default::default()
4553                            },
4554                        )),
4555                    },
4556                    WebUIFragment::raw(">"),
4557                    WebUIFragment::component("child-component"),
4558                    WebUIFragment::raw("</child-component>"),
4559                ],
4560            },
4561        );
4562        fragments.insert(
4563            "child-component".to_string(),
4564            FragmentList {
4565                fragments: vec![
4566                    WebUIFragment::raw("<h2>"),
4567                    WebUIFragment::signal("title", false),
4568                    WebUIFragment::raw("</h2>"),
4569                ],
4570            },
4571        );
4572        let protocol = WebUIProtocol::new(fragments);
4573        let state = test_json!({});
4574        let mut writer = TestWriter::new();
4575        handle(
4576            &protocol,
4577            &state,
4578            &RenderOptions::new("index.html", "/"),
4579            &mut writer,
4580        )
4581        .unwrap();
4582        assert_eq!(
4583            writer.get_content(),
4584            "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
4585        );
4586    }
4587
4588    #[test]
4589    fn test_attr_inherit_deep() {
4590        let mut fragments = HashMap::new();
4591        fragments.insert(
4592            "index.html".to_string(),
4593            FragmentList {
4594                fragments: vec![
4595                    WebUIFragment::raw("<parent-component"),
4596                    WebUIFragment {
4597                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4598                            WebUIFragmentAttribute {
4599                                name: "title".into(),
4600                                value: "Parent Title".into(),
4601                                raw_value: true,
4602                                attr_start: true,
4603                                ..Default::default()
4604                            },
4605                        )),
4606                    },
4607                    WebUIFragment::raw(">"),
4608                    WebUIFragment::component("parent-component"),
4609                    WebUIFragment::raw("</parent-component>"),
4610                ],
4611            },
4612        );
4613        fragments.insert(
4614            "parent-component".to_string(),
4615            FragmentList {
4616                fragments: vec![
4617                    WebUIFragment::raw("<child-component"),
4618                    WebUIFragment {
4619                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4620                            WebUIFragmentAttribute {
4621                                name: "title".into(),
4622                                value: "Child Title".into(),
4623                                raw_value: true,
4624                                attr_start: true,
4625                                ..Default::default()
4626                            },
4627                        )),
4628                    },
4629                    WebUIFragment::raw(">"),
4630                    WebUIFragment::component("child-component"),
4631                    WebUIFragment::raw("</child-component>"),
4632                ],
4633            },
4634        );
4635        fragments.insert(
4636            "child-component".to_string(),
4637            FragmentList {
4638                fragments: vec![
4639                    WebUIFragment::raw("<grandchild-component"),
4640                    WebUIFragment {
4641                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4642                            WebUIFragmentAttribute {
4643                                name: "title".into(),
4644                                value: "title".into(),
4645                                attr_start: true,
4646                                ..Default::default()
4647                            },
4648                        )),
4649                    },
4650                    WebUIFragment::raw(">"),
4651                    WebUIFragment::component("grandchild-component"),
4652                    WebUIFragment::raw("</grandchild-component>"),
4653                ],
4654            },
4655        );
4656        fragments.insert(
4657            "grandchild-component".to_string(),
4658            FragmentList {
4659                fragments: vec![
4660                    WebUIFragment::raw("<h3>"),
4661                    WebUIFragment::signal("title", false),
4662                    WebUIFragment::raw("</h3>"),
4663                ],
4664            },
4665        );
4666        let protocol = WebUIProtocol::new(fragments);
4667        let state = test_json!({});
4668        let mut writer = TestWriter::new();
4669        handle(
4670            &protocol,
4671            &state,
4672            &RenderOptions::new("index.html", "/"),
4673            &mut writer,
4674        )
4675        .unwrap();
4676        assert_eq!(
4677            writer.get_content(),
4678            "<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>"
4679        );
4680    }
4681
4682    #[test]
4683    fn test_complex_attr_access() {
4684        let mut fragments = HashMap::new();
4685        fragments.insert(
4686            "index.html".to_string(),
4687            FragmentList {
4688                fragments: vec![
4689                    WebUIFragment::raw("<my-component"),
4690                    WebUIFragment {
4691                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4692                            WebUIFragmentAttribute {
4693                                name: ":item".into(),
4694                                value: "complexItem".into(),
4695                                attr_start: true,
4696                                complex: true,
4697                                ..Default::default()
4698                            },
4699                        )),
4700                    },
4701                    WebUIFragment::raw(">"),
4702                    WebUIFragment::component("my-component"),
4703                    WebUIFragment::raw("</my-component>"),
4704                ],
4705            },
4706        );
4707        fragments.insert(
4708            "my-component".to_string(),
4709            FragmentList {
4710                fragments: vec![
4711                    WebUIFragment::raw("<span>"),
4712                    WebUIFragment::signal("item.foo", false),
4713                    WebUIFragment::raw("</span><p>"),
4714                    WebUIFragment::signal("item.bar", false),
4715                    WebUIFragment::raw("</p>"),
4716                ],
4717            },
4718        );
4719        let protocol = WebUIProtocol::new(fragments);
4720        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
4721        let mut writer = TestWriter::new();
4722        handle(
4723            &protocol,
4724            &state,
4725            &RenderOptions::new("index.html", "/"),
4726            &mut writer,
4727        )
4728        .unwrap();
4729        assert_eq!(
4730            writer.get_content(),
4731            "<my-component><span>1</span><p>true</p></my-component>"
4732        );
4733    }
4734
4735    #[test]
4736    fn test_complex_attr_for_loop() {
4737        let mut fragments = HashMap::new();
4738        fragments.insert(
4739            "index.html".to_string(),
4740            FragmentList {
4741                fragments: vec![WebUIFragment::for_loop(
4742                    "item",
4743                    "list.items",
4744                    "listTemplate",
4745                )],
4746            },
4747        );
4748        fragments.insert(
4749            "listTemplate".to_string(),
4750            FragmentList {
4751                fragments: vec![
4752                    WebUIFragment {
4753                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4754                            WebUIFragmentAttribute {
4755                                name: ":item".into(),
4756                                value: "item".into(),
4757                                attr_start: true,
4758                                complex: true,
4759                                ..Default::default()
4760                            },
4761                        )),
4762                    },
4763                    WebUIFragment::component("item_component"),
4764                ],
4765            },
4766        );
4767        fragments.insert(
4768            "item_component".to_string(),
4769            FragmentList {
4770                fragments: vec![
4771                    WebUIFragment::raw("<span>"),
4772                    WebUIFragment::signal("item.name", false),
4773                    WebUIFragment::raw("</span>"),
4774                ],
4775            },
4776        );
4777        let protocol = WebUIProtocol::new(fragments);
4778        let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
4779        let mut writer = TestWriter::new();
4780        handle(
4781            &protocol,
4782            &state,
4783            &RenderOptions::new("index.html", "/"),
4784            &mut writer,
4785        )
4786        .unwrap();
4787        assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
4788    }
4789
4790    #[test]
4791    fn test_complex_attr_nested_for() {
4792        let mut fragments = HashMap::new();
4793        fragments.insert(
4794            "index.html".to_string(),
4795            FragmentList {
4796                fragments: vec![WebUIFragment::for_loop(
4797                    "outer",
4798                    "data.outer",
4799                    "outerTemplate",
4800                )],
4801            },
4802        );
4803        fragments.insert(
4804            "outerTemplate".to_string(),
4805            FragmentList {
4806                fragments: vec![WebUIFragment::for_loop(
4807                    "middle",
4808                    "outer.middle",
4809                    "middleTemplate",
4810                )],
4811            },
4812        );
4813        fragments.insert(
4814            "middleTemplate".to_string(),
4815            FragmentList {
4816                fragments: vec![WebUIFragment::for_loop(
4817                    "inner",
4818                    "middle.inner",
4819                    "innerTemplate",
4820                )],
4821            },
4822        );
4823        fragments.insert(
4824            "innerTemplate".to_string(),
4825            FragmentList {
4826                fragments: vec![
4827                    WebUIFragment::raw("<card"),
4828                    WebUIFragment {
4829                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4830                            WebUIFragmentAttribute {
4831                                name: ":outer".into(),
4832                                value: "outer".into(),
4833                                attr_start: true,
4834                                complex: true,
4835                                ..Default::default()
4836                            },
4837                        )),
4838                    },
4839                    WebUIFragment {
4840                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4841                            WebUIFragmentAttribute {
4842                                name: ":middle".into(),
4843                                value: "middle".into(),
4844                                attr_start: false,
4845                                complex: true,
4846                                ..Default::default()
4847                            },
4848                        )),
4849                    },
4850                    WebUIFragment {
4851                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4852                            WebUIFragmentAttribute {
4853                                name: ":inner".into(),
4854                                value: "inner".into(),
4855                                attr_start: false,
4856                                complex: true,
4857                                ..Default::default()
4858                            },
4859                        )),
4860                    },
4861                    WebUIFragment::raw(">"),
4862                    WebUIFragment::component("card_component"),
4863                    WebUIFragment::raw("</card>"),
4864                ],
4865            },
4866        );
4867        fragments.insert(
4868            "card_component".to_string(),
4869            FragmentList {
4870                fragments: vec![
4871                    WebUIFragment::raw("<p>"),
4872                    WebUIFragment::signal("outer.label", false),
4873                    WebUIFragment::raw(" / "),
4874                    WebUIFragment::signal("middle.label", false),
4875                    WebUIFragment::raw(" / "),
4876                    WebUIFragment::signal("inner.label", false),
4877                    WebUIFragment::raw("</p>"),
4878                ],
4879            },
4880        );
4881        let protocol = WebUIProtocol::new(fragments);
4882        let state = test_json!({"data": {"outer": [
4883            {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
4884            {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
4885        ]}});
4886        let mut writer = TestWriter::new();
4887        handle(
4888            &protocol,
4889            &state,
4890            &RenderOptions::new("index.html", "/"),
4891            &mut writer,
4892        )
4893        .unwrap();
4894        assert_eq!(
4895            writer.get_content(),
4896            "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
4897        );
4898    }
4899
4900    // ── GROUP 8: Boolean Component State ──────────────────────────────
4901
4902    #[test]
4903    fn test_bool_component_state_true() {
4904        let mut fragments = HashMap::new();
4905        fragments.insert(
4906            "index.html".to_string(),
4907            FragmentList {
4908                fragments: vec![
4909                    WebUIFragment::raw("<my-component"),
4910                    WebUIFragment {
4911                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4912                            WebUIFragmentAttribute {
4913                                name: "disabled".into(),
4914                                attr_start: true,
4915                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4916                                ..Default::default()
4917                            },
4918                        )),
4919                    },
4920                    WebUIFragment::raw(">"),
4921                    WebUIFragment::component("my-component"),
4922                    WebUIFragment::raw("</my-component>"),
4923                ],
4924            },
4925        );
4926        fragments.insert(
4927            "my-component".to_string(),
4928            FragmentList {
4929                fragments: vec![
4930                    WebUIFragment::if_cond(
4931                        ConditionExpr::identifier("disabled"),
4932                        "disabledTemplate",
4933                    ),
4934                    WebUIFragment::if_cond(
4935                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
4936                        "enabledTemplate",
4937                    ),
4938                ],
4939            },
4940        );
4941        fragments.insert(
4942            "disabledTemplate".to_string(),
4943            FragmentList {
4944                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
4945            },
4946        );
4947        fragments.insert(
4948            "enabledTemplate".to_string(),
4949            FragmentList {
4950                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
4951            },
4952        );
4953        let protocol = WebUIProtocol::new(fragments);
4954        let state = test_json!({"isDisabled": true});
4955        let mut writer = TestWriter::new();
4956        handle(
4957            &protocol,
4958            &state,
4959            &RenderOptions::new("index.html", "/"),
4960            &mut writer,
4961        )
4962        .unwrap();
4963        assert_eq!(
4964            writer.get_content(),
4965            "<my-component disabled><span>Disabled</span></my-component>"
4966        );
4967    }
4968
4969    #[test]
4970    fn test_bool_component_state_false() {
4971        let mut fragments = HashMap::new();
4972        fragments.insert(
4973            "index.html".to_string(),
4974            FragmentList {
4975                fragments: vec![
4976                    WebUIFragment::raw("<my-component"),
4977                    WebUIFragment {
4978                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4979                            WebUIFragmentAttribute {
4980                                name: "disabled".into(),
4981                                attr_start: true,
4982                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4983                                ..Default::default()
4984                            },
4985                        )),
4986                    },
4987                    WebUIFragment::raw(">"),
4988                    WebUIFragment::component("my-component"),
4989                    WebUIFragment::raw("</my-component>"),
4990                ],
4991            },
4992        );
4993        fragments.insert(
4994            "my-component".to_string(),
4995            FragmentList {
4996                fragments: vec![
4997                    WebUIFragment::if_cond(
4998                        ConditionExpr::identifier("disabled"),
4999                        "disabledTemplate",
5000                    ),
5001                    WebUIFragment::if_cond(
5002                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
5003                        "enabledTemplate",
5004                    ),
5005                ],
5006            },
5007        );
5008        fragments.insert(
5009            "disabledTemplate".to_string(),
5010            FragmentList {
5011                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
5012            },
5013        );
5014        fragments.insert(
5015            "enabledTemplate".to_string(),
5016            FragmentList {
5017                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
5018            },
5019        );
5020        let protocol = WebUIProtocol::new(fragments);
5021        let state = test_json!({"isDisabled": false});
5022        let mut writer = TestWriter::new();
5023        handle(
5024            &protocol,
5025            &state,
5026            &RenderOptions::new("index.html", "/"),
5027            &mut writer,
5028        )
5029        .unwrap();
5030        assert_eq!(
5031            writer.get_content(),
5032            "<my-component><span>Enabled</span></my-component>"
5033        );
5034    }
5035
5036    #[test]
5037    fn test_bool_component_state_forward() {
5038        let mut fragments = HashMap::new();
5039        fragments.insert(
5040            "index.html".to_string(),
5041            FragmentList {
5042                fragments: vec![
5043                    WebUIFragment::raw("<parent-component"),
5044                    WebUIFragment {
5045                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5046                            WebUIFragmentAttribute {
5047                                name: "disabled".into(),
5048                                attr_start: true,
5049                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
5050                                ..Default::default()
5051                            },
5052                        )),
5053                    },
5054                    WebUIFragment::raw(">"),
5055                    WebUIFragment::component("parent-component"),
5056                    WebUIFragment::raw("</parent-component>"),
5057                ],
5058            },
5059        );
5060        fragments.insert(
5061            "parent-component".to_string(),
5062            FragmentList {
5063                fragments: vec![
5064                    WebUIFragment::if_cond(
5065                        ConditionExpr::identifier("disabled"),
5066                        "parentDisabledTemplate",
5067                    ),
5068                    WebUIFragment::raw("<child-component"),
5069                    WebUIFragment {
5070                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5071                            WebUIFragmentAttribute {
5072                                name: "disabled".into(),
5073                                attr_start: true,
5074                                condition_tree: Some(ConditionExpr::identifier("disabled")),
5075                                ..Default::default()
5076                            },
5077                        )),
5078                    },
5079                    WebUIFragment::raw(">"),
5080                    WebUIFragment::component("child-component"),
5081                    WebUIFragment::raw("</child-component>"),
5082                ],
5083            },
5084        );
5085        fragments.insert(
5086            "parentDisabledTemplate".to_string(),
5087            FragmentList {
5088                fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
5089            },
5090        );
5091        fragments.insert(
5092            "child-component".to_string(),
5093            FragmentList {
5094                fragments: vec![
5095                    WebUIFragment::if_cond(
5096                        ConditionExpr::identifier("disabled"),
5097                        "childDisabledTemplate",
5098                    ),
5099                    WebUIFragment::if_cond(
5100                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
5101                        "childEnabledTemplate",
5102                    ),
5103                ],
5104            },
5105        );
5106        fragments.insert(
5107            "childDisabledTemplate".to_string(),
5108            FragmentList {
5109                fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
5110            },
5111        );
5112        fragments.insert(
5113            "childEnabledTemplate".to_string(),
5114            FragmentList {
5115                fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
5116            },
5117        );
5118
5119        // Test case 1: isDisabled = true
5120        {
5121            let protocol = WebUIProtocol::new(fragments.clone());
5122            let state = test_json!({"isDisabled": true});
5123            let mut writer = TestWriter::new();
5124            handle(
5125                &protocol,
5126                &state,
5127                &RenderOptions::new("index.html", "/"),
5128                &mut writer,
5129            )
5130            .unwrap();
5131            assert_eq!(
5132                writer.get_content(),
5133                "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
5134            );
5135        }
5136
5137        // Test case 2: isDisabled = false
5138        {
5139            let protocol = WebUIProtocol::new(fragments.clone());
5140            let state = test_json!({"isDisabled": false});
5141            let mut writer = TestWriter::new();
5142            handle(
5143                &protocol,
5144                &state,
5145                &RenderOptions::new("index.html", "/"),
5146                &mut writer,
5147            )
5148            .unwrap();
5149            assert_eq!(
5150                writer.get_content(),
5151                "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
5152            );
5153        }
5154    }
5155
5156    // ── GROUP 9: Hydration (SKIP) ─────────────────────────────────────
5157
5158    // TODO: test_hydration - requires FAST handler plugin integration; see plugin/fast.rs
5159
5160    // ── Component tests ──────────────────────────────────────────────
5161
5162    #[test]
5163    fn test_component_with_template() {
5164        let mut fragments = HashMap::new();
5165        fragments.insert(
5166            "index.html".to_string(),
5167            FragmentList {
5168                fragments: vec![
5169                    WebUIFragment::raw("<custom-element>"),
5170                    WebUIFragment::component("custom-element"),
5171                    WebUIFragment::raw("</custom-element>"),
5172                ],
5173            },
5174        );
5175        fragments.insert(
5176            "custom-element".to_string(),
5177            FragmentList {
5178                fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
5179            },
5180        );
5181        let protocol = WebUIProtocol::new(fragments);
5182        let state = test_json!({});
5183        let mut writer = TestWriter::new();
5184        handle(
5185            &protocol,
5186            &state,
5187            &RenderOptions::new("index.html", "/"),
5188            &mut writer,
5189        )
5190        .unwrap();
5191        assert_eq!(
5192            writer.get_content(),
5193            "<custom-element><div>Custom Element</div></custom-element>"
5194        );
5195        assert!(writer.is_ended());
5196    }
5197
5198    #[test]
5199    fn test_component_with_slots() {
5200        let mut fragments = HashMap::new();
5201        fragments.insert(
5202            "index.html".to_string(),
5203            FragmentList {
5204                fragments: vec![
5205                    WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
5206                    WebUIFragment::component("custom-element"),
5207                    WebUIFragment::raw("Hello World</custom-element>"),
5208                ],
5209            },
5210        );
5211        fragments.insert(
5212            "custom-element".to_string(),
5213            FragmentList {
5214                fragments: vec![WebUIFragment::raw("<slot></slot>")],
5215            },
5216        );
5217        let protocol = WebUIProtocol::new(fragments);
5218        let state = test_json!({});
5219        let mut writer = TestWriter::new();
5220        handle(
5221            &protocol,
5222            &state,
5223            &RenderOptions::new("index.html", "/"),
5224            &mut writer,
5225        )
5226        .unwrap();
5227        assert_eq!(
5228            writer.get_content(),
5229            "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
5230        );
5231        assert!(writer.is_ended());
5232    }
5233
5234    #[test]
5235    fn test_multiple_nested_components() {
5236        let mut fragments = HashMap::new();
5237        fragments.insert(
5238            "index.html".to_string(),
5239            FragmentList {
5240                fragments: vec![
5241                    WebUIFragment::raw("<div>"),
5242                    WebUIFragment::for_loop("item", "items", "templateRepeat"),
5243                    WebUIFragment::raw("</div>"),
5244                ],
5245            },
5246        );
5247        fragments.insert(
5248            "custom-button".to_string(),
5249            FragmentList {
5250                fragments: vec![WebUIFragment::raw("<slot></slot>")],
5251            },
5252        );
5253        fragments.insert(
5254            "custom-element".to_string(),
5255            FragmentList {
5256                fragments: vec![
5257                    WebUIFragment::raw("<custom-child>"),
5258                    WebUIFragment::component("custom-child"),
5259                    WebUIFragment::raw("</custom-child><slot></slot>"),
5260                ],
5261            },
5262        );
5263        fragments.insert(
5264            "custom-child".to_string(),
5265            FragmentList {
5266                fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
5267            },
5268        );
5269        fragments.insert(
5270            "templateRepeat".to_string(),
5271            FragmentList {
5272                fragments: vec![
5273                    WebUIFragment::raw("<custom-element>"),
5274                    WebUIFragment::component("custom-element"),
5275                    WebUIFragment::raw("<custom-button>"),
5276                    WebUIFragment::component("custom-button"),
5277                    WebUIFragment::raw("Ok</custom-button></custom-element>"),
5278                ],
5279            },
5280        );
5281        let protocol = WebUIProtocol::new(fragments);
5282        let state = test_json!({"items": [{"name": "Item1"}]});
5283        let mut writer = TestWriter::new();
5284        handle(
5285            &protocol,
5286            &state,
5287            &RenderOptions::new("index.html", "/"),
5288            &mut writer,
5289        )
5290        .unwrap();
5291        assert_eq!(
5292            writer.get_content(),
5293            "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
5294        );
5295        assert!(writer.is_ended());
5296    }
5297
5298    // ── Conditional tests ────────────────────────────────────────────
5299
5300    #[test]
5301    fn test_if_with_binary_expression() {
5302        let mut fragments = HashMap::new();
5303        fragments.insert(
5304            "index.html".to_string(),
5305            FragmentList {
5306                fragments: vec![
5307                    WebUIFragment::raw("<div>"),
5308                    WebUIFragment::if_cond(
5309                        ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
5310                        "if-1",
5311                    ),
5312                    WebUIFragment::raw("</div>"),
5313                ],
5314            },
5315        );
5316        fragments.insert(
5317            "if-1".to_string(),
5318            FragmentList {
5319                fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
5320            },
5321        );
5322        let protocol = WebUIProtocol::new(fragments);
5323
5324        // True case: x = 10 > 5
5325        let state_true = test_json!({"x": 10});
5326        let mut writer_true = TestWriter::new();
5327        handle(
5328            &protocol,
5329            &state_true,
5330            &RenderOptions::new("index.html", "/"),
5331            &mut writer_true,
5332        )
5333        .unwrap();
5334        assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
5335
5336        // False case: x = 1 <= 5
5337        let state_false = test_json!({"x": 1});
5338        let mut writer_false = TestWriter::new();
5339        handle(
5340            &protocol,
5341            &state_false,
5342            &RenderOptions::new("index.html", "/"),
5343            &mut writer_false,
5344        )
5345        .unwrap();
5346        assert_eq!(writer_false.get_content(), "<div></div>");
5347    }
5348
5349    #[test]
5350    fn test_for_if_overlapping_local_state() {
5351        let mut fragments = HashMap::new();
5352        fragments.insert(
5353            "index.html".to_string(),
5354            FragmentList {
5355                fragments: vec![
5356                    WebUIFragment::raw("<div>"),
5357                    WebUIFragment::for_loop("item", "items", "template1"),
5358                    WebUIFragment::raw("</div>"),
5359                ],
5360            },
5361        );
5362        fragments.insert(
5363            "template1".to_string(),
5364            FragmentList {
5365                fragments: vec![
5366                    WebUIFragment::raw("<div>"),
5367                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
5368                    WebUIFragment::raw("</div>"),
5369                ],
5370            },
5371        );
5372        fragments.insert(
5373            "ifBlock".to_string(),
5374            FragmentList {
5375                fragments: vec![
5376                    WebUIFragment::raw("<span>"),
5377                    WebUIFragment::signal("item.label", false),
5378                    WebUIFragment::raw("</span>"),
5379                ],
5380            },
5381        );
5382        let protocol = WebUIProtocol::new(fragments);
5383        let state = test_json!({
5384            "flag": false,
5385            "items": [
5386                {"label": "A", "flag": true},
5387                {"label": "B", "flag": false},
5388                {"label": "C", "flag": true}
5389            ]
5390        });
5391        let mut writer = TestWriter::new();
5392        handle(
5393            &protocol,
5394            &state,
5395            &RenderOptions::new("index.html", "/"),
5396            &mut writer,
5397        )
5398        .unwrap();
5399        assert_eq!(
5400            writer.get_content(),
5401            "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
5402        );
5403    }
5404
5405    #[test]
5406    fn test_for_if_global_flag_no_effect() {
5407        let mut fragments = HashMap::new();
5408        fragments.insert(
5409            "index.html".to_string(),
5410            FragmentList {
5411                fragments: vec![
5412                    WebUIFragment::raw("<div>"),
5413                    WebUIFragment::for_loop("item", "items", "template1"),
5414                    WebUIFragment::raw("</div>"),
5415                ],
5416            },
5417        );
5418        fragments.insert(
5419            "template1".to_string(),
5420            FragmentList {
5421                fragments: vec![
5422                    WebUIFragment::raw("<div>"),
5423                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
5424                    WebUIFragment::raw("</div>"),
5425                ],
5426            },
5427        );
5428        fragments.insert(
5429            "ifBlock".to_string(),
5430            FragmentList {
5431                fragments: vec![
5432                    WebUIFragment::raw("<span>"),
5433                    WebUIFragment::signal("item.label", false),
5434                    WebUIFragment::raw("</span>"),
5435                ],
5436            },
5437        );
5438        let protocol = WebUIProtocol::new(fragments);
5439        let state = test_json!({
5440            "item": {"flag": true},
5441            "items": [
5442                {"label": "A", "flag": false},
5443                {"label": "B", "flag": true},
5444                {"label": "C", "flag": false}
5445            ]
5446        });
5447        let mut writer = TestWriter::new();
5448        handle(
5449            &protocol,
5450            &state,
5451            &RenderOptions::new("index.html", "/"),
5452            &mut writer,
5453        )
5454        .unwrap();
5455        assert_eq!(
5456            writer.get_content(),
5457            "<div><div></div><div><span>B</span></div><div></div></div>"
5458        );
5459    }
5460
5461    // ── Recursive template test ──────────────────────────────────────
5462
5463    #[test]
5464    fn test_recursive_template_refs() {
5465        let mut fragments = HashMap::new();
5466        fragments.insert(
5467            "index.html".to_string(),
5468            FragmentList {
5469                fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
5470            },
5471        );
5472        fragments.insert(
5473            "static".to_string(),
5474            FragmentList {
5475                fragments: vec![
5476                    WebUIFragment::raw("<div expanded=\""),
5477                    WebUIFragment::signal("item.expanded", false),
5478                    WebUIFragment::raw("\" class=\""),
5479                    WebUIFragment::signal("testScenario", false),
5480                    WebUIFragment::raw("\"><span>"),
5481                    WebUIFragment::signal("item.name", false),
5482                    WebUIFragment::raw("</span>"),
5483                    WebUIFragment::for_loop("item", "item.children", "static"),
5484                    WebUIFragment::raw("</div>"),
5485                ],
5486            },
5487        );
5488        let protocol = WebUIProtocol::new(fragments);
5489        let state = test_json!({
5490            "testScenario": "RecursiveTemplatesWithGlobalState",
5491            "items": [
5492                {"name": "A", "expanded": "false", "children": []},
5493                {"name": "B", "expanded": "true", "children": [
5494                    {"name": "C", "expanded": "false"},
5495                    {"name": "D", "expanded": "false"}
5496                ]},
5497                {"name": "E", "expanded": "false"}
5498            ]
5499        });
5500        let mut writer = TestWriter::new();
5501        handle(
5502            &protocol,
5503            &state,
5504            &RenderOptions::new("index.html", "/"),
5505            &mut writer,
5506        )
5507        .unwrap();
5508        assert_eq!(
5509            writer.get_content(),
5510            "<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>"
5511        );
5512    }
5513
5514    // ── Advanced state management tests ──────────────────────────────
5515
5516    #[test]
5517    fn test_component_in_for_no_local_access() {
5518        let mut fragments = HashMap::new();
5519        fragments.insert(
5520            "index.html".to_string(),
5521            FragmentList {
5522                fragments: vec![
5523                    WebUIFragment::raw("<div>"),
5524                    WebUIFragment::for_loop("item", "items", "templateComponent"),
5525                    WebUIFragment::raw("</div>"),
5526                ],
5527            },
5528        );
5529        fragments.insert(
5530            "templateComponent".to_string(),
5531            FragmentList {
5532                fragments: vec![
5533                    WebUIFragment::raw("<component-tag>"),
5534                    WebUIFragment::component("my-component"),
5535                    WebUIFragment::raw("</component-tag>"),
5536                ],
5537            },
5538        );
5539        fragments.insert(
5540            "my-component".to_string(),
5541            FragmentList {
5542                fragments: vec![
5543                    WebUIFragment::raw("<span>"),
5544                    WebUIFragment::signal("name", false),
5545                    WebUIFragment::raw("</span>"),
5546                ],
5547            },
5548        );
5549        let protocol = WebUIProtocol::new(fragments);
5550        let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
5551        let mut writer = TestWriter::new();
5552        handle(
5553            &protocol,
5554            &state,
5555            &RenderOptions::new("index.html", "/"),
5556            &mut writer,
5557        )
5558        .unwrap();
5559        assert_eq!(
5560            writer.get_content(),
5561            "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
5562        );
5563    }
5564
5565    #[test]
5566    fn test_nested_for_hierarchical_state() {
5567        let mut fragments = HashMap::new();
5568        fragments.insert(
5569            "index.html".to_string(),
5570            FragmentList {
5571                fragments: vec![
5572                    WebUIFragment::raw("<div>"),
5573                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5574                    WebUIFragment::raw("</div>"),
5575                ],
5576            },
5577        );
5578        fragments.insert(
5579            "outerTemplate".to_string(),
5580            FragmentList {
5581                fragments: vec![
5582                    WebUIFragment::raw("<section>"),
5583                    WebUIFragment::signal("globalPrefix", false),
5584                    WebUIFragment::signal("outerItem.outerLabel", false),
5585                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5586                    WebUIFragment::raw("</section>"),
5587                ],
5588            },
5589        );
5590        fragments.insert(
5591            "innerTemplate".to_string(),
5592            FragmentList {
5593                fragments: vec![
5594                    WebUIFragment::raw("<p>"),
5595                    WebUIFragment::signal("globalPrefix", false),
5596                    WebUIFragment::signal("outerItem.outerLabel", false),
5597                    WebUIFragment::raw(": "),
5598                    WebUIFragment::signal("innerItem.innerLabel", false),
5599                    WebUIFragment::raw("</p>"),
5600                ],
5601            },
5602        );
5603        let protocol = WebUIProtocol::new(fragments);
5604        let state = test_json!({
5605            "globalPrefix": "Prefix: ",
5606            "outerItems": [
5607                {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
5608                {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
5609            ]
5610        });
5611        let mut writer = TestWriter::new();
5612        handle(
5613            &protocol,
5614            &state,
5615            &RenderOptions::new("index.html", "/"),
5616            &mut writer,
5617        )
5618        .unwrap();
5619        assert_eq!(
5620            writer.get_content(),
5621            "<div><section>Prefix: O1<p>Prefix: O1: I1</p><p>Prefix: O1: I2</p></section><section>Prefix: O2<p>Prefix: O2: I3</p></section></div>"
5622        );
5623    }
5624
5625    #[test]
5626    fn test_component_in_for_global_only() {
5627        let mut fragments = HashMap::new();
5628        fragments.insert(
5629            "index.html".to_string(),
5630            FragmentList {
5631                fragments: vec![
5632                    WebUIFragment::raw("<div>"),
5633                    WebUIFragment::for_loop("item", "items", "templateComponent"),
5634                    WebUIFragment::raw("</div>"),
5635                ],
5636            },
5637        );
5638        fragments.insert(
5639            "templateComponent".to_string(),
5640            FragmentList {
5641                fragments: vec![
5642                    WebUIFragment::raw("<component-tag>"),
5643                    WebUIFragment::component("my-component"),
5644                    WebUIFragment::raw("</component-tag>"),
5645                ],
5646            },
5647        );
5648        fragments.insert(
5649            "my-component".to_string(),
5650            FragmentList {
5651                fragments: vec![
5652                    WebUIFragment::raw("<span>"),
5653                    WebUIFragment::signal("name", false),
5654                    WebUIFragment::raw("-"),
5655                    WebUIFragment::signal("globalSuffix", false),
5656                    WebUIFragment::raw("</span>"),
5657                ],
5658            },
5659        );
5660        let protocol = WebUIProtocol::new(fragments);
5661        let state =
5662            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
5663        let mut writer = TestWriter::new();
5664        handle(
5665            &protocol,
5666            &state,
5667            &RenderOptions::new("index.html", "/"),
5668            &mut writer,
5669        )
5670        .unwrap();
5671        assert_eq!(
5672            writer.get_content(),
5673            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
5674        );
5675    }
5676
5677    #[test]
5678    fn test_component_no_item_moniker() {
5679        let mut fragments = HashMap::new();
5680        fragments.insert(
5681            "index.html".to_string(),
5682            FragmentList {
5683                fragments: vec![
5684                    WebUIFragment::raw("<div>"),
5685                    WebUIFragment::for_loop("item", "items", "templateComponent"),
5686                    WebUIFragment::raw("</div>"),
5687                ],
5688            },
5689        );
5690        fragments.insert(
5691            "templateComponent".to_string(),
5692            FragmentList {
5693                fragments: vec![
5694                    WebUIFragment::raw("<component-tag>"),
5695                    WebUIFragment::component("my-component"),
5696                    WebUIFragment::raw("</component-tag>"),
5697                ],
5698            },
5699        );
5700        fragments.insert(
5701            "my-component".to_string(),
5702            FragmentList {
5703                fragments: vec![
5704                    WebUIFragment::raw("<span>"),
5705                    WebUIFragment::signal("item.name", false),
5706                    WebUIFragment::raw("-"),
5707                    WebUIFragment::signal("globalSuffix", false),
5708                    WebUIFragment::raw("</span>"),
5709                ],
5710            },
5711        );
5712        let protocol = WebUIProtocol::new(fragments);
5713        let state =
5714            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
5715        let mut writer = TestWriter::new();
5716        handle(
5717            &protocol,
5718            &state,
5719            &RenderOptions::new("index.html", "/"),
5720            &mut writer,
5721        )
5722        .unwrap();
5723        assert_eq!(
5724            writer.get_content(),
5725            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
5726        );
5727    }
5728
5729    #[test]
5730    fn test_for_nonqualified_uses_global() {
5731        let mut fragments = HashMap::new();
5732        fragments.insert(
5733            "index.html".to_string(),
5734            FragmentList {
5735                fragments: vec![
5736                    WebUIFragment::raw("<div>"),
5737                    WebUIFragment::for_loop("item", "items", "template1"),
5738                    WebUIFragment::raw("</div>"),
5739                ],
5740            },
5741        );
5742        fragments.insert(
5743            "template1".to_string(),
5744            FragmentList {
5745                fragments: vec![
5746                    WebUIFragment::raw("<span>"),
5747                    WebUIFragment::signal("name", false),
5748                    WebUIFragment::raw("</span>"),
5749                ],
5750            },
5751        );
5752        let protocol = WebUIProtocol::new(fragments);
5753        let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
5754        let mut writer = TestWriter::new();
5755        handle(
5756            &protocol,
5757            &state,
5758            &RenderOptions::new("index.html", "/"),
5759            &mut writer,
5760        )
5761        .unwrap();
5762        assert_eq!(
5763            writer.get_content(),
5764            "<div><span>GlobalName</span><span>GlobalName</span></div>"
5765        );
5766    }
5767
5768    #[test]
5769    fn test_nested_for_if_interleaved() {
5770        let mut fragments = HashMap::new();
5771        fragments.insert(
5772            "index.html".to_string(),
5773            FragmentList {
5774                fragments: vec![
5775                    WebUIFragment::raw("<div>"),
5776                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5777                    WebUIFragment::raw("</div>"),
5778                ],
5779            },
5780        );
5781        fragments.insert(
5782            "outerTemplate".to_string(),
5783            FragmentList {
5784                fragments: vec![
5785                    WebUIFragment::raw("<section>"),
5786                    WebUIFragment::signal("globalPrefix", false),
5787                    WebUIFragment::signal("outerItem.outerLabel", false),
5788                    WebUIFragment::if_cond(
5789                        ConditionExpr::identifier("outerItem.include"),
5790                        "ifTemplate",
5791                    ),
5792                    WebUIFragment::raw("</section>"),
5793                ],
5794            },
5795        );
5796        fragments.insert(
5797            "ifTemplate".to_string(),
5798            FragmentList {
5799                fragments: vec![
5800                    WebUIFragment::raw("<div>"),
5801                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5802                    WebUIFragment::raw("</div>"),
5803                ],
5804            },
5805        );
5806        fragments.insert(
5807            "innerTemplate".to_string(),
5808            FragmentList {
5809                fragments: vec![
5810                    WebUIFragment::raw("<p>"),
5811                    WebUIFragment::signal("globalSuffix", false),
5812                    WebUIFragment::raw(": "),
5813                    WebUIFragment::signal("innerItem.innerLabel", false),
5814                    WebUIFragment::raw("</p>"),
5815                ],
5816            },
5817        );
5818        let protocol = WebUIProtocol::new(fragments);
5819        let state = test_json!({
5820            "globalPrefix": "Prefix: ",
5821            "globalSuffix": "Suffix",
5822            "outerItems": [
5823                {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
5824                {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
5825                {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
5826            ]
5827        });
5828        let mut writer = TestWriter::new();
5829        handle(
5830            &protocol,
5831            &state,
5832            &RenderOptions::new("index.html", "/"),
5833            &mut writer,
5834        )
5835        .unwrap();
5836        assert_eq!(
5837            writer.get_content(),
5838            "<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>"
5839        );
5840    }
5841
5842    #[test]
5843    fn test_nested_for_if_outer_state() {
5844        let mut fragments = HashMap::new();
5845        fragments.insert(
5846            "index.html".to_string(),
5847            FragmentList {
5848                fragments: vec![
5849                    WebUIFragment::raw("<div>"),
5850                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5851                    WebUIFragment::raw("</div>"),
5852                ],
5853            },
5854        );
5855        fragments.insert(
5856            "outerTemplate".to_string(),
5857            FragmentList {
5858                fragments: vec![
5859                    WebUIFragment::raw("<section>"),
5860                    WebUIFragment::signal("globalPrefix", false),
5861                    WebUIFragment::signal("outerItem.label", false),
5862                    WebUIFragment::for_loop(
5863                        "middleItem",
5864                        "outerItem.middleItems",
5865                        "middleTemplate",
5866                    ),
5867                    WebUIFragment::raw("</section>"),
5868                ],
5869            },
5870        );
5871        fragments.insert(
5872            "middleTemplate".to_string(),
5873            FragmentList {
5874                fragments: vec![
5875                    WebUIFragment::raw("<div>"),
5876                    WebUIFragment::if_cond(
5877                        ConditionExpr::identifier("outerItem.active"),
5878                        "ifTemplate",
5879                    ),
5880                    WebUIFragment::raw("</div>"),
5881                ],
5882            },
5883        );
5884        fragments.insert(
5885            "ifTemplate".to_string(),
5886            FragmentList {
5887                fragments: vec![
5888                    WebUIFragment::raw("<p>"),
5889                    WebUIFragment::signal("middleItem.value", false),
5890                    WebUIFragment::raw("</p>"),
5891                ],
5892            },
5893        );
5894        let protocol = WebUIProtocol::new(fragments);
5895        let state = test_json!({
5896            "globalPrefix": "GP-",
5897            "outerItems": [
5898                {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
5899                {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
5900                {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
5901            ]
5902        });
5903        let mut writer = TestWriter::new();
5904        handle(
5905            &protocol,
5906            &state,
5907            &RenderOptions::new("index.html", "/"),
5908            &mut writer,
5909        )
5910        .unwrap();
5911        assert_eq!(
5912            writer.get_content(),
5913            "<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>"
5914        );
5915    }
5916
5917    #[test]
5918    fn test_nested_for_if_inner_state() {
5919        let mut fragments = HashMap::new();
5920        fragments.insert(
5921            "index.html".to_string(),
5922            FragmentList {
5923                fragments: vec![
5924                    WebUIFragment::raw("<div>"),
5925                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5926                    WebUIFragment::raw("</div>"),
5927                ],
5928            },
5929        );
5930        fragments.insert(
5931            "outerTemplate".to_string(),
5932            FragmentList {
5933                fragments: vec![
5934                    WebUIFragment::raw("<section>"),
5935                    WebUIFragment::signal("outerItem.label", false),
5936                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5937                    WebUIFragment::raw("</section>"),
5938                ],
5939            },
5940        );
5941        fragments.insert(
5942            "innerTemplate".to_string(),
5943            FragmentList {
5944                fragments: vec![
5945                    WebUIFragment::raw("<article>"),
5946                    WebUIFragment::if_cond(
5947                        ConditionExpr::identifier("innerItem.show"),
5948                        "ifTemplate",
5949                    ),
5950                    WebUIFragment::raw("</article>"),
5951                ],
5952            },
5953        );
5954        fragments.insert(
5955            "ifTemplate".to_string(),
5956            FragmentList {
5957                fragments: vec![
5958                    WebUIFragment::raw("<p>"),
5959                    WebUIFragment::signal("innerItem.detail", false),
5960                    WebUIFragment::raw("</p>"),
5961                ],
5962            },
5963        );
5964        let protocol = WebUIProtocol::new(fragments);
5965        let state = test_json!({
5966            "outerItems": [
5967                {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
5968                {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
5969            ]
5970        });
5971        let mut writer = TestWriter::new();
5972        handle(
5973            &protocol,
5974            &state,
5975            &RenderOptions::new("index.html", "/"),
5976            &mut writer,
5977        )
5978        .unwrap();
5979        assert_eq!(
5980            writer.get_content(),
5981            "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
5982        );
5983    }
5984
5985    #[test]
5986    fn test_for_merge_local_global_monikers() {
5987        let mut fragments = HashMap::new();
5988        fragments.insert(
5989            "index.html".to_string(),
5990            FragmentList {
5991                fragments: vec![
5992                    WebUIFragment::raw("<div>"),
5993                    WebUIFragment::for_loop("item", "items", "template1"),
5994                    WebUIFragment::raw("</div>"),
5995                ],
5996            },
5997        );
5998        fragments.insert(
5999            "template1".to_string(),
6000            FragmentList {
6001                fragments: vec![
6002                    WebUIFragment::raw("<span>"),
6003                    WebUIFragment::signal("item.name", false),
6004                    WebUIFragment::raw("-"),
6005                    WebUIFragment::signal("item.globalValue", false),
6006                    WebUIFragment::raw("-"),
6007                    WebUIFragment::signal("item.localOnly", false),
6008                    WebUIFragment::raw("-"),
6009                    WebUIFragment::signal("item.otherVal", false),
6010                    WebUIFragment::raw("</span>"),
6011                ],
6012            },
6013        );
6014        let protocol = WebUIProtocol::new(fragments);
6015        let state = test_json!({
6016            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
6017            "items": [
6018                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
6019                {"name": "Local2", "localOnly": "Only2"}
6020            ]
6021        });
6022        let mut writer = TestWriter::new();
6023        handle(
6024            &protocol,
6025            &state,
6026            &RenderOptions::new("index.html", "/"),
6027            &mut writer,
6028        )
6029        .unwrap();
6030        assert_eq!(
6031            writer.get_content(),
6032            "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
6033        );
6034    }
6035
6036    #[test]
6037    fn test_component_in_for_global_moniker_shadow() {
6038        let mut fragments = HashMap::new();
6039        fragments.insert(
6040            "index.html".to_string(),
6041            FragmentList {
6042                fragments: vec![
6043                    WebUIFragment::raw("<div>"),
6044                    WebUIFragment::for_loop("item", "items", "templateComponent"),
6045                    WebUIFragment::raw("</div>"),
6046                ],
6047            },
6048        );
6049        fragments.insert(
6050            "templateComponent".to_string(),
6051            FragmentList {
6052                fragments: vec![
6053                    WebUIFragment::raw("<component-tag>"),
6054                    WebUIFragment::component("my-component"),
6055                    WebUIFragment::raw("</component-tag>"),
6056                ],
6057            },
6058        );
6059        fragments.insert(
6060            "my-component".to_string(),
6061            FragmentList {
6062                fragments: vec![
6063                    WebUIFragment::raw("<span>"),
6064                    WebUIFragment::signal("name", false),
6065                    WebUIFragment::raw("-"),
6066                    WebUIFragment::signal("item.globalValue", false),
6067                    WebUIFragment::raw("-"),
6068                    WebUIFragment::signal("localOnly", false),
6069                    WebUIFragment::raw("-"),
6070                    WebUIFragment::signal("item.otherVal", false),
6071                    WebUIFragment::raw("</span>"),
6072                ],
6073            },
6074        );
6075        let protocol = WebUIProtocol::new(fragments);
6076        let state = test_json!({
6077            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
6078            "items": [
6079                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
6080                {"name": "Local2", "localOnly": "Only2"}
6081            ]
6082        });
6083        let mut writer = TestWriter::new();
6084        handle(
6085            &protocol,
6086            &state,
6087            &RenderOptions::new("index.html", "/"),
6088            &mut writer,
6089        )
6090        .unwrap();
6091        assert_eq!(
6092            writer.get_content(),
6093            "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
6094        );
6095    }
6096
6097    #[test]
6098    fn test_if_in_nested_for_local_flag() {
6099        let mut fragments = HashMap::new();
6100        fragments.insert(
6101            "index.html".to_string(),
6102            FragmentList {
6103                fragments: vec![
6104                    WebUIFragment::raw("<div>"),
6105                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
6106                    WebUIFragment::raw("</div>"),
6107                ],
6108            },
6109        );
6110        fragments.insert(
6111            "outerTemplate".to_string(),
6112            FragmentList {
6113                fragments: vec![
6114                    WebUIFragment::raw("<section>"),
6115                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
6116                    WebUIFragment::raw("</section>"),
6117                ],
6118            },
6119        );
6120        fragments.insert(
6121            "innerTemplate".to_string(),
6122            FragmentList {
6123                fragments: vec![WebUIFragment::if_cond(
6124                    ConditionExpr::identifier("inner_item.flag"),
6125                    "ifInner",
6126                )],
6127            },
6128        );
6129        fragments.insert(
6130            "ifInner".to_string(),
6131            FragmentList {
6132                fragments: vec![
6133                    WebUIFragment::raw("<p>"),
6134                    WebUIFragment::signal("inner_item.value", false),
6135                    WebUIFragment::raw("</p>"),
6136                ],
6137            },
6138        );
6139        let protocol = WebUIProtocol::new(fragments);
6140        let state = test_json!({
6141            "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
6142            "inner_item": {"flag": false}
6143        });
6144        let mut writer = TestWriter::new();
6145        handle(
6146            &protocol,
6147            &state,
6148            &RenderOptions::new("index.html", "/"),
6149            &mut writer,
6150        )
6151        .unwrap();
6152        assert_eq!(
6153            writer.get_content(),
6154            "<div><section><p>X</p></section></div>"
6155        );
6156    }
6157
6158    #[test]
6159    fn test_if_in_nested_for_global_fallback() {
6160        let mut fragments = HashMap::new();
6161        fragments.insert(
6162            "index.html".to_string(),
6163            FragmentList {
6164                fragments: vec![
6165                    WebUIFragment::raw("<div>"),
6166                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
6167                    WebUIFragment::raw("</div>"),
6168                ],
6169            },
6170        );
6171        fragments.insert(
6172            "outerTemplate".to_string(),
6173            FragmentList {
6174                fragments: vec![
6175                    WebUIFragment::raw("<section>"),
6176                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
6177                    WebUIFragment::raw("</section>"),
6178                ],
6179            },
6180        );
6181        fragments.insert(
6182            "innerTemplate".to_string(),
6183            FragmentList {
6184                fragments: vec![WebUIFragment::if_cond(
6185                    ConditionExpr::identifier("inner_item.flag"),
6186                    "ifInner",
6187                )],
6188            },
6189        );
6190        fragments.insert(
6191            "ifInner".to_string(),
6192            FragmentList {
6193                fragments: vec![
6194                    WebUIFragment::raw("<p>"),
6195                    WebUIFragment::signal("inner_item.value", false),
6196                    WebUIFragment::raw("</p>"),
6197                ],
6198            },
6199        );
6200        let protocol = WebUIProtocol::new(fragments);
6201        let state = test_json!({
6202            "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
6203            "inner_item": {"flag": true}
6204        });
6205        let mut writer = TestWriter::new();
6206        handle(
6207            &protocol,
6208            &state,
6209            &RenderOptions::new("index.html", "/"),
6210            &mut writer,
6211        )
6212        .unwrap();
6213        assert_eq!(
6214            writer.get_content(),
6215            "<div><section><p>X</p><p>Y</p></section></div>"
6216        );
6217    }
6218
6219    #[test]
6220    fn test_if_mixed_for_monikers() {
6221        let mut fragments = HashMap::new();
6222        fragments.insert(
6223            "index.html".to_string(),
6224            FragmentList {
6225                fragments: vec![
6226                    WebUIFragment::raw("<div>"),
6227                    WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
6228                    WebUIFragment::raw("</div>"),
6229                ],
6230            },
6231        );
6232        fragments.insert(
6233            "outerTemplate".to_string(),
6234            FragmentList {
6235                fragments: vec![
6236                    WebUIFragment::raw("<section>"),
6237                    WebUIFragment::signal("outer.outerLabel", false),
6238                    WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
6239                    WebUIFragment::raw("</section>"),
6240                ],
6241            },
6242        );
6243        fragments.insert(
6244            "innerTemplate".to_string(),
6245            FragmentList {
6246                fragments: vec![WebUIFragment::if_cond(
6247                    ConditionExpr::compound(
6248                        ConditionExpr::identifier("outer.active"),
6249                        LogicalOperator::And,
6250                        ConditionExpr::predicate(
6251                            "inner.value",
6252                            ComparisonOperator::GreaterThan,
6253                            "globalLimit",
6254                        ),
6255                    ),
6256                    "ifInner",
6257                )],
6258            },
6259        );
6260        fragments.insert(
6261            "ifInner".to_string(),
6262            FragmentList {
6263                fragments: vec![
6264                    WebUIFragment::raw("<p>"),
6265                    WebUIFragment::signal("inner.value", false),
6266                    WebUIFragment::raw("</p>"),
6267                ],
6268            },
6269        );
6270        let protocol = WebUIProtocol::new(fragments);
6271        let state = test_json!({
6272            "globalLimit": 10,
6273            "list": {"outerItems": [
6274                {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
6275                {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
6276                {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
6277            ]}
6278        });
6279        let mut writer = TestWriter::new();
6280        handle(
6281            &protocol,
6282            &state,
6283            &RenderOptions::new("index.html", "/"),
6284            &mut writer,
6285        )
6286        .unwrap();
6287        assert_eq!(
6288            writer.get_content(),
6289            "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
6290        );
6291    }
6292
6293    // ── Route-aware rendering tests ─────────────────────────────────────
6294
6295    fn make_route_protocol() -> WebUIProtocol {
6296        use webui_protocol::WebUiFragmentRoute;
6297
6298        let mut fragments = HashMap::new();
6299
6300        // Entry page with two routes
6301        fragments.insert(
6302            "index.html".to_string(),
6303            FragmentList {
6304                fragments: vec![
6305                    WebUIFragment::raw("<h1>Shell</h1>"),
6306                    WebUIFragment::route_from(WebUiFragmentRoute {
6307                        path: "/".into(),
6308                        fragment_id: "dash-page".into(),
6309                        exact: true,
6310                        keep_alive: false,
6311                        ..Default::default()
6312                    }),
6313                    WebUIFragment::route_from(WebUiFragmentRoute {
6314                        path: "/contacts/:id".into(),
6315                        fragment_id: "detail-page".into(),
6316                        exact: true,
6317                        keep_alive: false,
6318                        ..Default::default()
6319                    }),
6320                ],
6321            },
6322        );
6323
6324        // Dashboard page component
6325        fragments.insert(
6326            "dash-page".to_string(),
6327            FragmentList {
6328                fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
6329            },
6330        );
6331
6332        // Detail page component
6333        fragments.insert(
6334            "detail-page".to_string(),
6335            FragmentList {
6336                fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
6337            },
6338        );
6339
6340        WebUIProtocol::new(fragments)
6341    }
6342
6343    fn make_nested_route_protocol() -> WebUIProtocol {
6344        use webui_protocol::WebUiFragmentRoute;
6345
6346        let mut fragments = HashMap::new();
6347
6348        fragments.insert(
6349            "index.html".to_string(),
6350            FragmentList {
6351                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
6352                    path: "/".into(),
6353                    fragment_id: "app-shell".into(),
6354                    exact: false,
6355                    children: vec![WebUiFragmentRoute {
6356                        path: "sections/:id".into(),
6357                        fragment_id: "section-comp".into(),
6358                        exact: false,
6359                        children: vec![WebUiFragmentRoute {
6360                            path: "topics/:topicId".into(),
6361                            fragment_id: "topic-comp".into(),
6362                            exact: true,
6363                            children: vec![],
6364                            keep_alive: false,
6365                            ..Default::default()
6366                        }],
6367                        keep_alive: false,
6368                        ..Default::default()
6369                    }],
6370                    keep_alive: false,
6371                    ..Default::default()
6372                })],
6373            },
6374        );
6375
6376        fragments.insert(
6377            "app-shell".to_string(),
6378            FragmentList {
6379                fragments: vec![
6380                    WebUIFragment::raw("<h1>Shell</h1>"),
6381                    WebUIFragment::outlet(),
6382                ],
6383            },
6384        );
6385
6386        fragments.insert(
6387            "section-comp".to_string(),
6388            FragmentList {
6389                fragments: vec![
6390                    WebUIFragment::raw("<h2>Section</h2>"),
6391                    WebUIFragment::outlet(),
6392                ],
6393            },
6394        );
6395
6396        fragments.insert(
6397            "topic-comp".to_string(),
6398            FragmentList {
6399                fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
6400            },
6401        );
6402
6403        WebUIProtocol::new(fragments)
6404    }
6405
6406    #[test]
6407    fn test_route_renders_shell_always() {
6408        let protocol = make_route_protocol();
6409        let state = test_json!({});
6410        let mut writer = TestWriter::new();
6411        handle(
6412            &protocol,
6413            &state,
6414            &RenderOptions::new("index.html", "/"),
6415            &mut writer,
6416        )
6417        .unwrap();
6418        let html = writer.get_content();
6419
6420        // Shell content always renders regardless of route matching
6421        assert!(html.contains("<h1>Shell</h1>"), "shell should render");
6422        // Dashboard matches "/" so it should be active
6423        assert!(html.contains(" active>"), "matched route should be active");
6424        // Detail should be hidden and empty
6425        assert!(
6426            html.contains("style=\"display:none\""),
6427            "non-matched routes should be hidden"
6428        );
6429    }
6430
6431    #[test]
6432    fn test_route_matched_renders_visible() {
6433        let protocol = make_route_protocol();
6434        let state = test_json!({});
6435        let mut writer = TestWriter::new();
6436        handle(
6437            &protocol,
6438            &state,
6439            &RenderOptions::new("index.html", "/"),
6440            &mut writer,
6441        )
6442        .unwrap();
6443        let html = writer.get_content();
6444
6445        // Dashboard route should be visible (active, no display:none)
6446        assert!(
6447            html.contains("<webui-route path=\"/\""),
6448            "dashboard route should exist"
6449        );
6450        assert!(
6451            html.contains("active>") && html.contains("<dash-page>"),
6452            "matched route should be active with component tag: {html}"
6453        );
6454        assert!(
6455            html.contains("<p>Dashboard</p>"),
6456            "matched route should have content"
6457        );
6458    }
6459
6460    #[test]
6461    fn test_route_non_matched_renders_hidden_empty() {
6462        let protocol = make_route_protocol();
6463        let state = test_json!({});
6464        let mut writer = TestWriter::new();
6465        handle(
6466            &protocol,
6467            &state,
6468            &RenderOptions::new("index.html", "/"),
6469            &mut writer,
6470        )
6471        .unwrap();
6472        let html = writer.get_content();
6473
6474        // Detail route should be hidden and empty (no content rendered)
6475        assert!(
6476            html.contains("<webui-route path=\"/contacts/:id\""),
6477            "detail route element should exist"
6478        );
6479        // The non-matched route should have display:none and no inner content
6480        let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
6481        let after_detail = &html[detail_start..];
6482        assert!(
6483            after_detail.contains("style=\"display:none\">")
6484                && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
6485            "non-matched route should be hidden: {after_detail}"
6486        );
6487        // Should NOT contain the component's rendered content
6488        let detail_end = after_detail.find("</webui-route>").expect("closing tag");
6489        let detail_body = &after_detail[..detail_end];
6490        assert!(
6491            !detail_body.contains("<detail-page>"),
6492            "non-matched route should not render component content: {detail_body}"
6493        );
6494    }
6495
6496    #[test]
6497    fn test_route_parameterized_match() {
6498        let protocol = make_route_protocol();
6499        let state = test_json!({});
6500        let mut writer = TestWriter::new();
6501        handle(
6502            &protocol,
6503            &state,
6504            &RenderOptions::new("index.html", "/contacts/42"),
6505            &mut writer,
6506        )
6507        .unwrap();
6508        let html = writer.get_content();
6509
6510        // Detail route matches /contacts/42
6511        assert!(
6512            html.contains("active>") && html.contains("<detail-page>"),
6513            "detail route should be active: {html}"
6514        );
6515        assert!(html.contains("<p>Detail</p>"), "detail should have content");
6516        // Dashboard should be hidden + empty
6517        let dash_start = html
6518            .find("component=\"dash-page\"")
6519            .expect("dashboard route");
6520        let after_dash = &html[dash_start..];
6521        assert!(
6522            after_dash.contains("style=\"display:none\">"),
6523            "dashboard should be hidden when detail matches: {after_dash}"
6524        );
6525        let dash_end = after_dash.find("</webui-route>").expect("closing tag");
6526        let dash_body = &after_dash[..dash_end];
6527        assert!(
6528            !dash_body.contains("<dash-page>"),
6529            "dashboard should not render component content: {dash_body}"
6530        );
6531    }
6532
6533    #[test]
6534    fn test_route_no_match_all_hidden_empty() {
6535        let protocol = make_route_protocol();
6536        let state = test_json!({});
6537        let mut writer = TestWriter::new();
6538        handle(
6539            &protocol,
6540            &state,
6541            &RenderOptions::new("index.html", "/nonexistent"),
6542            &mut writer,
6543        )
6544        .unwrap();
6545        let html = writer.get_content();
6546
6547        // Shell content should still render
6548        assert!(html.contains("<h1>Shell</h1>"));
6549        // All routes should be hidden + empty (nothing matched)
6550        assert!(
6551            !html.contains("<p>Dashboard</p>"),
6552            "no route content when nothing matches"
6553        );
6554        assert!(
6555            !html.contains("<p>Detail</p>"),
6556            "no route content when nothing matches"
6557        );
6558    }
6559
6560    #[test]
6561    fn test_route_component_attr_emitted() {
6562        let protocol = make_route_protocol();
6563        let state = test_json!({});
6564        let mut writer = TestWriter::new();
6565        handle(
6566            &protocol,
6567            &state,
6568            &RenderOptions::new("index.html", "/"),
6569            &mut writer,
6570        )
6571        .unwrap();
6572        let html = writer.get_content();
6573        // component attribute should be emitted on webui-route
6574        assert!(
6575            html.contains("component=\"dash-page\""),
6576            "component attr should be on webui-route: {html}"
6577        );
6578        assert!(
6579            html.contains("component=\"detail-page\""),
6580            "component attr should be on webui-route: {html}"
6581        );
6582    }
6583
6584    #[test]
6585    fn test_no_plugin_no_state_attributes() {
6586        let protocol = make_route_protocol();
6587        let state = test_json!({
6588            "title": "Fish & Chips",
6589            "cartOpen": true,
6590            "items": [{"name": "A&B"}]
6591        });
6592        let mut writer = TestWriter::new();
6593
6594        handle(
6595            &protocol,
6596            &state,
6597            &RenderOptions::new("index.html", "/"),
6598            &mut writer,
6599        )
6600        .unwrap();
6601
6602        let html = writer.get_content();
6603        // Without a plugin, no state attributes at all
6604        assert!(
6605            !html.contains("data-state"),
6606            "no data-state without plugin: {html}"
6607        );
6608        assert!(
6609            !html.contains(r#"title="Fish"#),
6610            "no scalar attrs without plugin: {html}"
6611        );
6612    }
6613
6614    #[test]
6615    fn test_nested_routes_render_webui_route_as_light_dom() {
6616        let protocol = make_nested_route_protocol();
6617        let state = test_json!({"title": "Test"});
6618        let handler = WebUIHandler::new();
6619        let mut writer = TestWriter::new();
6620
6621        handler
6622            .handle(
6623                &protocol,
6624                &state,
6625                &RenderOptions::new("index.html", "/sections/frontend"),
6626                &mut writer,
6627            )
6628            .expect("render failed");
6629
6630        let html = writer.get_content();
6631
6632        assert!(
6633            html.contains("component=\"app-shell\"") && html.contains("active>"),
6634            "root route should be active: {html}"
6635        );
6636        // webui-route should NOT have shadow DOM — it's a light DOM structural element
6637        assert!(
6638            !html.contains("<template shadowrootmode"),
6639            "webui-route should be light DOM (no shadow template): {html}"
6640        );
6641    }
6642
6643    #[test]
6644    fn test_nested_routes_render_outlet_as_light_dom() {
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", "/sections/frontend"),
6655                &mut writer,
6656            )
6657            .expect("render failed");
6658
6659        let html = writer.get_content();
6660
6661        // No <webui-outlet> wrapper — routes render directly at outlet position
6662        assert!(
6663            !html.contains("<webui-outlet>"),
6664            "should not contain webui-outlet wrapper: {html}"
6665        );
6666        // Route elements should be in the output directly
6667        assert!(
6668            html.contains("<webui-route"),
6669            "should contain webui-route elements: {html}"
6670        );
6671    }
6672
6673    #[test]
6674    fn test_nested_routes_match_child_at_outlet() {
6675        let protocol = make_nested_route_protocol();
6676        let state = test_json!({"title": "Test"});
6677        let handler = WebUIHandler::new();
6678        let mut writer = TestWriter::new();
6679
6680        handler
6681            .handle(
6682                &protocol,
6683                &state,
6684                &RenderOptions::new("index.html", "/sections/frontend"),
6685                &mut writer,
6686            )
6687            .expect("render failed");
6688
6689        let html = writer.get_content();
6690
6691        assert!(
6692            html.contains("component=\"section-comp\"") && html.contains("active>"),
6693            "section route should be active: {html}"
6694        );
6695        assert!(
6696            html.contains("<h2>Section</h2>"),
6697            "section content should be present: {html}"
6698        );
6699    }
6700
6701    #[test]
6702    fn test_nested_routes_three_levels_deep() {
6703        let protocol = make_nested_route_protocol();
6704        let state = test_json!({"title": "Test"});
6705        let handler = WebUIHandler::new();
6706        let mut writer = TestWriter::new();
6707
6708        handler
6709            .handle(
6710                &protocol,
6711                &state,
6712                &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
6713                &mut writer,
6714            )
6715            .expect("render failed");
6716
6717        let html = writer.get_content();
6718
6719        assert!(
6720            html.contains("component=\"app-shell\"") && html.contains("active>"),
6721            "root active: {html}"
6722        );
6723        assert!(
6724            html.contains("component=\"section-comp\"") && html.contains("active>"),
6725            "section active: {html}"
6726        );
6727        assert!(
6728            html.contains("component=\"topic-comp\"")
6729                && html.contains("exact")
6730                && html.contains("active>"),
6731            "topic active: {html}"
6732        );
6733        assert!(
6734            html.contains("<p>Topic content</p>"),
6735            "leaf content present: {html}"
6736        );
6737    }
6738
6739    #[test]
6740    fn test_nested_routes_nonmatched_siblings_hidden() {
6741        let protocol = make_nested_route_protocol();
6742        let state = test_json!({"title": "Test"});
6743        let handler = WebUIHandler::new();
6744        let mut writer = TestWriter::new();
6745
6746        handler
6747            .handle(
6748                &protocol,
6749                &state,
6750                &RenderOptions::new("index.html", "/sections/frontend"),
6751                &mut writer,
6752            )
6753            .expect("render failed");
6754
6755        let html = writer.get_content();
6756
6757        assert!(
6758            html.contains(r#"component="topic-comp" exact style="display:none">"#),
6759            "topic should be hidden: {html}"
6760        );
6761    }
6762
6763    #[test]
6764    fn test_nested_routes_root_only() {
6765        let protocol = make_nested_route_protocol();
6766        let state = test_json!({"title": "Test"});
6767        let handler = WebUIHandler::new();
6768        let mut writer = TestWriter::new();
6769
6770        handler
6771            .handle(
6772                &protocol,
6773                &state,
6774                &RenderOptions::new("index.html", "/"),
6775                &mut writer,
6776            )
6777            .expect("render failed");
6778
6779        let html = writer.get_content();
6780
6781        assert!(
6782            html.contains("component=\"app-shell\"") && html.contains("active>"),
6783            "root active at /: {html}"
6784        );
6785        assert!(
6786            html.contains("<h1>Shell</h1>"),
6787            "shell renders at /: {html}"
6788        );
6789        assert!(
6790            html.contains(r#"component="section-comp" style="display:none">"#),
6791            "section hidden at /: {html}"
6792        );
6793    }
6794
6795    // ── CSS Module dedup tests ───────────────────────────────────────
6796
6797    #[test]
6798    fn test_css_module_emitted_once_inline_in_component() {
6799        // CSS module definition emitted once in the component's light DOM
6800        // on first render, not in <head> and not on second instance.
6801        let template = r#"<p><slot></slot></p>"#;
6802
6803        let mut fragments = HashMap::new();
6804        fragments.insert(
6805            "index.html".to_string(),
6806            FragmentList {
6807                fragments: vec![
6808                    WebUIFragment::raw("<html><head>".to_string()),
6809                    structural_fragment("head_end"),
6810                    WebUIFragment::raw("</head><body><div>".to_string()),
6811                    WebUIFragment::component("my-card"),
6812                    WebUIFragment::raw("A".to_string()),
6813                    WebUIFragment::component("my-card"),
6814                    WebUIFragment::raw("B</div>".to_string()),
6815                    structural_fragment("body_end"),
6816                    WebUIFragment::raw("</body></html>".to_string()),
6817                ],
6818            },
6819        );
6820        fragments.insert(
6821            "my-card".to_string(),
6822            FragmentList {
6823                fragments: vec![WebUIFragment::raw(template.to_string())],
6824            },
6825        );
6826
6827        let mut protocol = WebUIProtocol::new(fragments);
6828        protocol
6829            .components
6830            .entry("my-card".to_string())
6831            .or_default()
6832            .css = "p{color:red}".to_string();
6833        let state = test_json!({});
6834        let mut writer = TestWriter::new();
6835
6836        handle(
6837            &protocol,
6838            &state,
6839            &RenderOptions::new("index.html", "/"),
6840            &mut writer,
6841        )
6842        .unwrap();
6843
6844        let html = writer.get_content();
6845
6846        // CSS module importmap should appear exactly once
6847        let count = html.matches(r#"<script type="importmap""#).count();
6848        assert_eq!(
6849            count, 1,
6850            "CSS module importmap should be emitted once, got {count} in: {html}"
6851        );
6852        assert!(
6853            html.contains(r#""my-card":"data:text/css,"#),
6854            "Importmap must register my-card under a data: URI: {html}"
6855        );
6856
6857        // Template content should appear twice (once per component instance)
6858        let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
6859        assert_eq!(
6860            tmpl_count, 2,
6861            "Template should render twice, got {tmpl_count} in: {html}"
6862        );
6863
6864        // CSS module should be in <body> (inline), not in <head>
6865        let css_pos = html
6866            .find(r#"<script type="importmap""#)
6867            .expect("CSS module importmap missing");
6868        let body_pos = html.find("<body>").expect("<body> missing");
6869        assert!(
6870            css_pos > body_pos,
6871            "CSS module should be inline in component, not in <head>: {html}"
6872        );
6873    }
6874
6875    #[test]
6876    fn test_component_without_css_renders_normally() {
6877        // Components without CSS module prefix pass through unchanged
6878        let mut fragments = HashMap::new();
6879        fragments.insert(
6880            "index.html".to_string(),
6881            FragmentList {
6882                fragments: vec![WebUIFragment::component("my-card")],
6883            },
6884        );
6885        fragments.insert(
6886            "my-card".to_string(),
6887            FragmentList {
6888                fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
6889            },
6890        );
6891
6892        let protocol = WebUIProtocol::new(fragments);
6893        let state = test_json!({});
6894        let mut writer = TestWriter::new();
6895
6896        handle(
6897            &protocol,
6898            &state,
6899            &RenderOptions::new("index.html", "/"),
6900            &mut writer,
6901        )
6902        .unwrap();
6903
6904        let html = writer.get_content();
6905        assert!(
6906            html.contains("<p>hello</p>"),
6907            "Non-module component should render normally: {html}"
6908        );
6909    }
6910
6911    #[test]
6912    fn test_non_module_strategy_no_css_in_head() {
6913        // When component_css is empty (Link/Style strategies), no
6914        // CSS module importmap tags should appear in <head>.
6915        let template = r#"<p>hello</p>"#;
6916
6917        let mut fragments = HashMap::new();
6918        fragments.insert(
6919            "index.html".to_string(),
6920            FragmentList {
6921                fragments: vec![
6922                    WebUIFragment::raw("<html><head>".to_string()),
6923                    structural_fragment("head_end"),
6924                    WebUIFragment::raw("</head><body>".to_string()),
6925                    WebUIFragment::component("my-card"),
6926                    WebUIFragment::raw("</body></html>".to_string()),
6927                ],
6928            },
6929        );
6930        fragments.insert(
6931            "my-card".to_string(),
6932            FragmentList {
6933                fragments: vec![WebUIFragment::raw(template.to_string())],
6934            },
6935        );
6936
6937        // No component css populated — simulates Link/Style strategy
6938        let protocol = WebUIProtocol::new(fragments);
6939        let state = test_json!({});
6940        let mut writer = TestWriter::new();
6941
6942        handle(
6943            &protocol,
6944            &state,
6945            &RenderOptions::new("index.html", "/"),
6946            &mut writer,
6947        )
6948        .unwrap();
6949
6950        let html = writer.get_content();
6951
6952        assert!(
6953            !html.contains(r#"<style type="module""#),
6954            "Non-module strategy should not emit legacy CSS module tags in <head>: {html}"
6955        );
6956        assert!(
6957            !html.contains(r#"<script type="importmap""#),
6958            "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
6959        );
6960        assert!(
6961            html.contains("<p>hello</p>"),
6962            "Component should still render: {html}"
6963        );
6964    }
6965
6966    #[test]
6967    fn test_style_strategy_embeds_inline_style_in_shadow_template() {
6968        let mut fragments = HashMap::new();
6969        fragments.insert(
6970            "index.html".to_string(),
6971            FragmentList {
6972                fragments: vec![
6973                    WebUIFragment::raw("<html><head>".to_string()),
6974                    structural_fragment("head_end"),
6975                    WebUIFragment::raw("</head><body><my-card>".to_string()),
6976                    WebUIFragment::component("my-card"),
6977                    WebUIFragment::raw("</my-card></body></html>".to_string()),
6978                ],
6979            },
6980        );
6981        fragments.insert(
6982            "my-card".to_string(),
6983            FragmentList {
6984                fragments: vec![WebUIFragment::raw(
6985                    "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
6986                        .to_string(),
6987                )],
6988            },
6989        );
6990
6991        let protocol = WebUIProtocol::new(fragments);
6992        let state = test_json!({});
6993        let mut writer = TestWriter::new();
6994
6995        handle(
6996            &protocol,
6997            &state,
6998            &RenderOptions::new("index.html", "/"),
6999            &mut writer,
7000        )
7001        .unwrap();
7002
7003        let html = writer.get_content();
7004
7005        assert!(
7006            html.contains("<style>.card{color:red}</style>"),
7007            "Style strategy should embed inline CSS in shadow template: {html}"
7008        );
7009        assert!(
7010            !html.contains(r#"<style type="module""#),
7011            "Style strategy should not emit legacy module CSS in <head>: {html}"
7012        );
7013        assert!(
7014            !html.contains(r#"<script type="importmap""#),
7015            "Style strategy should not emit CSS module importmaps in <head>: {html}"
7016        );
7017    }
7018
7019    #[test]
7020    fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
7021        // Light DOM + Link strategy: handler emits <link rel="stylesheet">
7022        // in <head>. No preload tag — the stylesheet itself fetches.
7023        let mut fragments = HashMap::new();
7024        fragments.insert(
7025            "index.html".to_string(),
7026            FragmentList {
7027                fragments: vec![
7028                    WebUIFragment::raw("<html><head>".to_string()),
7029                    structural_fragment("head_end"),
7030                    WebUIFragment::raw("</head><body><my-card>".to_string()),
7031                    WebUIFragment::component("my-card"),
7032                    WebUIFragment::raw("</my-card>".to_string()),
7033                    structural_fragment("body_end"),
7034                    WebUIFragment::raw("</body></html>".to_string()),
7035                ],
7036            },
7037        );
7038        fragments.insert(
7039            "my-card".to_string(),
7040            FragmentList {
7041                fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
7042            },
7043        );
7044
7045        let mut protocol = WebUIProtocol::new(fragments);
7046        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
7047        protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
7048
7049        let comp = protocol
7050            .components
7051            .entry("my-card".to_string())
7052            .or_default();
7053        comp.css_href = "my-card.css".to_string();
7054        comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
7055
7056        let state = test_json!({});
7057        let mut writer = TestWriter::new();
7058
7059        handle(
7060            &protocol,
7061            &state,
7062            &RenderOptions::new("index.html", "/"),
7063            &mut writer,
7064        )
7065        .unwrap();
7066
7067        let html = writer.get_content();
7068
7069        let head_end = html.find("</head>").expect("</head> missing");
7070        let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css">"#);
7071        assert!(
7072            link_pos.is_some_and(|p| p < head_end),
7073            "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
7074        );
7075        assert!(
7076            !html.contains(r#"<link rel="preload""#),
7077            "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
7078        );
7079        assert!(
7080            !html.contains(r#"<style type="module""#),
7081            "Link strategy should not emit legacy module CSS: {html}"
7082        );
7083        assert!(
7084            !html.contains(r#"<script type="importmap""#),
7085            "Link strategy should not emit CSS module importmaps: {html}"
7086        );
7087    }
7088
7089    #[test]
7090    fn test_link_strategy_shadow_dom_emits_preload_in_head() {
7091        // Shadow DOM + Link strategy: handler emits <link rel="preload">
7092        // with data-webui-ssr-preload in <head>. No stylesheet — the shadow
7093        // root template already contains <link rel="stylesheet">.
7094        let mut fragments = HashMap::new();
7095        fragments.insert(
7096            "index.html".to_string(),
7097            FragmentList {
7098                fragments: vec![
7099                    WebUIFragment::raw("<html><head>".to_string()),
7100                    structural_fragment("head_end"),
7101                    WebUIFragment::raw("</head><body><o-loading-state>".to_string()),
7102                    WebUIFragment::component("o-loading-state"),
7103                    WebUIFragment::raw("</o-loading-state><my-card>".to_string()),
7104                    WebUIFragment::component("my-card"),
7105                    WebUIFragment::raw("</my-card>".to_string()),
7106                    structural_fragment("body_end"),
7107                    WebUIFragment::raw("</body></html>".to_string()),
7108                ],
7109            },
7110        );
7111        fragments.insert(
7112            "o-loading-state".to_string(),
7113            FragmentList {
7114                fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
7115            },
7116        );
7117        fragments.insert(
7118            "my-card".to_string(),
7119            FragmentList {
7120                fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
7121            },
7122        );
7123
7124        let mut protocol = WebUIProtocol::new(fragments);
7125        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
7126        protocol.set_dom_strategy(webui_protocol::DomStrategy::Shadow);
7127
7128        let comp1 = protocol
7129            .components
7130            .entry("o-loading-state".to_string())
7131            .or_default();
7132        comp1.css_href = "o-loading-state.css".to_string();
7133        comp1.template_json = r#"{"h":"<div>loading</div>"}"#.to_string();
7134
7135        let comp2 = protocol
7136            .components
7137            .entry("my-card".to_string())
7138            .or_default();
7139        comp2.css_href = "my-card.css".to_string();
7140        comp2.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
7141
7142        let state = test_json!({});
7143        let mut writer = TestWriter::new();
7144
7145        handle(
7146            &protocol,
7147            &state,
7148            &RenderOptions::new("index.html", "/"),
7149            &mut writer,
7150        )
7151        .unwrap();
7152
7153        let html = writer.get_content();
7154        let head_end = html.find("</head>").expect("</head> missing");
7155        let head_section = &html[..head_end];
7156
7157        // Both preload hints must be present with data-webui-ssr-preload attr
7158        assert!(
7159            head_section.contains(
7160                r#"<link rel="preload" href="o-loading-state.css" as="style" data-webui-ssr-preload="style">"#
7161            ),
7162            "Missing preload for o-loading-state.css in <head>: {html}"
7163        );
7164        assert!(
7165            head_section.contains(
7166                r#"<link rel="preload" href="my-card.css" as="style" data-webui-ssr-preload="style">"#
7167            ),
7168            "Missing preload for my-card.css in <head>: {html}"
7169        );
7170        // No stylesheet links — shadow root handles that
7171        assert!(
7172            !head_section.contains(r#"<link rel="stylesheet""#),
7173            "Shadow DOM should NOT emit <link rel=stylesheet> in <head>: {html}"
7174        );
7175    }
7176
7177    #[test]
7178    fn test_module_preloads_emit_in_head_in_compiler_order() {
7179        // The whole value of these hints is ordering: preloads are issued in
7180        // document order over one connection, so the largest chunk must go
7181        // first. The compiler sorts; the handler must not reorder or dedupe.
7182        let mut fragments = HashMap::new();
7183        fragments.insert(
7184            "index.html".to_string(),
7185            FragmentList {
7186                fragments: vec![
7187                    WebUIFragment::raw(
7188                        r#"<html><head><script type="module" async src="/index.js"></script>"#
7189                            .to_string(),
7190                    ),
7191                    structural_fragment("head_end"),
7192                    WebUIFragment::raw("</head><body>".to_string()),
7193                    structural_fragment("body_end"),
7194                    WebUIFragment::raw("</body></html>".to_string()),
7195                ],
7196            },
7197        );
7198
7199        let mut protocol = WebUIProtocol::new(fragments);
7200        protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
7201
7202        let state = test_json!({});
7203        let mut writer = TestWriter::new();
7204        handle(
7205            &protocol,
7206            &state,
7207            &RenderOptions::new("index.html", "/"),
7208            &mut writer,
7209        )
7210        .unwrap();
7211
7212        let html = writer.get_content();
7213        let head_end = html.find("</head>").expect("</head> missing");
7214        let head = &html[..head_end];
7215        assert!(
7216            head.contains(
7217                r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
7218            ),
7219            "hints must appear in <head> in the compiler's order: {html}"
7220        );
7221    }
7222
7223    #[test]
7224    fn test_no_module_preloads_emits_nothing() {
7225        let mut fragments = HashMap::new();
7226        fragments.insert(
7227            "index.html".to_string(),
7228            FragmentList {
7229                fragments: vec![
7230                    WebUIFragment::raw("<html><head>".to_string()),
7231                    structural_fragment("head_end"),
7232                    WebUIFragment::raw("</head><body>".to_string()),
7233                    structural_fragment("body_end"),
7234                    WebUIFragment::raw("</body></html>".to_string()),
7235                ],
7236            },
7237        );
7238        let protocol = WebUIProtocol::new(fragments);
7239
7240        let state = test_json!({});
7241        let mut writer = TestWriter::new();
7242        handle(
7243            &protocol,
7244            &state,
7245            &RenderOptions::new("index.html", "/"),
7246            &mut writer,
7247        )
7248        .unwrap();
7249
7250        assert!(
7251            !writer.get_content().contains("modulepreload"),
7252            "a build without hints must be byte-identical to before"
7253        );
7254    }
7255
7256    #[test]
7257    fn test_link_strategy_head_links_follow_document_order() {
7258        // Regression for #381: Link-strategy <head> CSS <link> tags must be
7259        // emitted in document/traversal order, not alphabetical tag order.
7260        // Document order here is <z-widget> then <a-widget>; an alphabetical
7261        // sort would (incorrectly) place a-widget first.
7262        let mut fragments = HashMap::new();
7263        fragments.insert(
7264            "index.html".to_string(),
7265            FragmentList {
7266                fragments: vec![
7267                    WebUIFragment::raw("<html><head>".to_string()),
7268                    structural_fragment("head_end"),
7269                    WebUIFragment::raw("</head><body><z-widget>".to_string()),
7270                    WebUIFragment::component("z-widget"),
7271                    WebUIFragment::raw("</z-widget><a-widget>".to_string()),
7272                    WebUIFragment::component("a-widget"),
7273                    WebUIFragment::raw("</a-widget>".to_string()),
7274                    structural_fragment("body_end"),
7275                    WebUIFragment::raw("</body></html>".to_string()),
7276                ],
7277            },
7278        );
7279        fragments.insert(
7280            "z-widget".to_string(),
7281            FragmentList {
7282                fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
7283            },
7284        );
7285        fragments.insert(
7286            "a-widget".to_string(),
7287            FragmentList {
7288                fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
7289            },
7290        );
7291
7292        let mut protocol = WebUIProtocol::new(fragments);
7293        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
7294        protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
7295
7296        let z = protocol
7297            .components
7298            .entry("z-widget".to_string())
7299            .or_default();
7300        z.css_href = "z-widget.css".to_string();
7301        z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
7302
7303        let a = protocol
7304            .components
7305            .entry("a-widget".to_string())
7306            .or_default();
7307        a.css_href = "a-widget.css".to_string();
7308        a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
7309
7310        let state = test_json!({});
7311        let mut writer = TestWriter::new();
7312
7313        handle(
7314            &protocol,
7315            &state,
7316            &RenderOptions::new("index.html", "/"),
7317            &mut writer,
7318        )
7319        .unwrap();
7320
7321        let html = writer.get_content();
7322        let head_end = html.find("</head>").expect("</head> missing");
7323        let head_section = &html[..head_end];
7324
7325        let z_pos = head_section
7326            .find(r#"<link rel="stylesheet" href="z-widget.css">"#)
7327            .expect("z-widget stylesheet link missing from <head>");
7328        let a_pos = head_section
7329            .find(r#"<link rel="stylesheet" href="a-widget.css">"#)
7330            .expect("a-widget stylesheet link missing from <head>");
7331
7332        assert!(
7333            z_pos < a_pos,
7334            "CSS <link> tags must follow document order (z-widget before \
7335             a-widget), not alphabetical order: {html}"
7336        );
7337    }
7338
7339    #[test]
7340    fn test_css_module_emitted_in_component_light_dom() {
7341        // CSS module <style> tags are emitted inline in the component's light DOM,
7342        // not in <head>. This keeps SSR output lean — only rendered components
7343        // get their style definitions.
7344        let mut fragments = HashMap::new();
7345        fragments.insert(
7346            "index.html".to_string(),
7347            FragmentList {
7348                fragments: vec![
7349                    WebUIFragment::raw("<html><head>".to_string()),
7350                    structural_fragment("head_end"),
7351                    WebUIFragment::raw("</head><body><my-card>".to_string()),
7352                    WebUIFragment::component("my-card"),
7353                    WebUIFragment::raw("</my-card>".to_string()),
7354                    WebUIFragment::raw("</body></html>".to_string()),
7355                ],
7356            },
7357        );
7358        fragments.insert(
7359            "my-card".to_string(),
7360            FragmentList {
7361                fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
7362            },
7363        );
7364
7365        let mut protocol = WebUIProtocol::new(fragments);
7366        protocol
7367            .components
7368            .entry("my-card".to_string())
7369            .or_default()
7370            .css = "p{color:red}".to_string();
7371        let state = test_json!({});
7372        let mut writer = TestWriter::new();
7373
7374        handle(
7375            &protocol,
7376            &state,
7377            &RenderOptions::new("index.html", "/"),
7378            &mut writer,
7379        )
7380        .unwrap();
7381
7382        let html = writer.get_content();
7383
7384        // CSS module importmap must be INSIDE the component tag (light DOM)
7385        let tag_open = html.find("<my-card>").expect("<my-card> missing");
7386        let css_pos = html
7387            .find(r#"<script type="importmap""#)
7388            .expect("CSS module importmap missing");
7389        let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
7390        assert!(
7391            css_pos > tag_open && css_pos < tag_close,
7392            "CSS module should be inside component light DOM: {html}"
7393        );
7394
7395        // <head> should NOT contain module styles
7396        let head_end = html.find("</head>").expect("</head> missing");
7397        assert!(
7398            css_pos > head_end,
7399            "CSS module should not be in <head>: {html}"
7400        );
7401    }
7402
7403    #[test]
7404    fn test_css_module_emitted_for_route_components() {
7405        // Route components get CSS modules emitted inline in their light DOM.
7406        let template = r#"<h1>Dashboard</h1>"#;
7407
7408        let mut fragments = HashMap::new();
7409        fragments.insert(
7410            "index.html".to_string(),
7411            FragmentList {
7412                fragments: vec![
7413                    WebUIFragment::raw("<html><head>".to_string()),
7414                    structural_fragment("head_end"),
7415                    WebUIFragment::raw("</head><body>".to_string()),
7416                    WebUIFragment::route("/", "dash-page"),
7417                    structural_fragment("body_end"),
7418                    WebUIFragment::raw("</body></html>".to_string()),
7419                ],
7420            },
7421        );
7422        fragments.insert(
7423            "dash-page".to_string(),
7424            FragmentList {
7425                fragments: vec![WebUIFragment::raw(template.to_string())],
7426            },
7427        );
7428
7429        let mut protocol = WebUIProtocol::new(fragments);
7430        let comp = protocol
7431            .components
7432            .entry("dash-page".to_string())
7433            .or_default();
7434        comp.css = "h1{font-size:2rem}".to_string();
7435        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
7436        let state = test_json!({});
7437        let mut writer = TestWriter::new();
7438
7439        handle(
7440            &protocol,
7441            &state,
7442            &RenderOptions::new("index.html", "/"),
7443            &mut writer,
7444        )
7445        .unwrap();
7446
7447        let html = writer.get_content();
7448
7449        assert!(
7450            html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
7451            "Route component should have CSS module importmap with data: URI: {html}"
7452        );
7453        assert!(
7454            html.contains("<h1>Dashboard</h1>"),
7455            "Route component should render content: {html}"
7456        );
7457    }
7458
7459    #[test]
7460    fn test_head_css_link_skipped_for_components_without_css() {
7461        // Regression: components without CSS files must not get <link> tags
7462        // in <head>, otherwise the browser requests a 404.
7463        let mut fragments = HashMap::new();
7464        fragments.insert(
7465            "index.html".to_string(),
7466            FragmentList {
7467                fragments: vec![
7468                    WebUIFragment::raw("<html><head>".to_string()),
7469                    structural_fragment("head_end"),
7470                    WebUIFragment::raw("</head><body>".to_string()),
7471                    WebUIFragment::component("has-css"),
7472                    WebUIFragment::component("no-css"),
7473                    structural_fragment("body_end"),
7474                    WebUIFragment::raw("</body></html>".to_string()),
7475                ],
7476            },
7477        );
7478        fragments.insert(
7479            "has-css".to_string(),
7480            FragmentList {
7481                fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
7482            },
7483        );
7484        fragments.insert(
7485            "no-css".to_string(),
7486            FragmentList {
7487                fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
7488            },
7489        );
7490
7491        let mut protocol = WebUIProtocol::new(fragments);
7492        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
7493        protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
7494
7495        // Only has-css has an external stylesheet (Link strategy)
7496        protocol
7497            .components
7498            .entry("has-css".to_string())
7499            .or_default()
7500            .css_href = "has-css.css".to_string();
7501
7502        let state = test_json!({});
7503        let mut writer = TestWriter::new();
7504
7505        let handler = WebUIHandler::with_plugin(|| {
7506            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7507        });
7508        handler
7509            .handle(
7510                &protocol,
7511                &state,
7512                &RenderOptions::new("index.html", "/"),
7513                &mut writer,
7514            )
7515            .unwrap();
7516
7517        let html = writer.get_content();
7518        assert!(
7519            html.contains(r#"<link rel="stylesheet" href="has-css.css">"#),
7520            "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
7521        );
7522        assert!(
7523            !html.contains("no-css.css"),
7524            "Component without CSS must NOT get a <link> in <head>: {html}"
7525        );
7526    }
7527
7528    #[test]
7529    fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
7530        // Simulates a page where app-shell renders cart-panel, but cart-panel
7531        // contains an <if> block with product-card inside. When the condition
7532        // is false (empty cart), product-card is NOT rendered — but it IS
7533        // reachable from the fragment graph. Its template metadata and CSS module
7534        // definition must be in the output so the client can mount it when
7535        // the <if> flips true. However, its bit must NOT be set in the
7536        // inventory — the inventory tracks what was actually rendered.
7537        let mut fragments = HashMap::new();
7538        fragments.insert(
7539            "index.html".to_string(),
7540            FragmentList {
7541                fragments: vec![
7542                    WebUIFragment::raw("<html><head>".to_string()),
7543                    structural_fragment("head_end"),
7544                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
7545                    WebUIFragment::component("app-shell"),
7546                    WebUIFragment::raw("</app-shell>".to_string()),
7547                    structural_fragment("body_end"),
7548                    WebUIFragment::raw("</body></html>".to_string()),
7549                ],
7550            },
7551        );
7552        // app-shell contains a cart panel
7553        fragments.insert(
7554            "app-shell".to_string(),
7555            FragmentList {
7556                fragments: vec![
7557                    WebUIFragment::raw("<div>Shell</div>".to_string()),
7558                    WebUIFragment::component("cart-panel"),
7559                ],
7560            },
7561        );
7562        // cart-panel has an <if> block containing product-card
7563        fragments.insert(
7564            "cart-panel".to_string(),
7565            FragmentList {
7566                fragments: vec![
7567                    WebUIFragment::raw("<aside>".to_string()),
7568                    WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
7569                    WebUIFragment::raw("</aside>".to_string()),
7570                ],
7571            },
7572        );
7573        // cart-items (if block body) contains product-card
7574        fragments.insert(
7575            "cart-items".to_string(),
7576            FragmentList {
7577                fragments: vec![WebUIFragment::component("product-card")],
7578            },
7579        );
7580        fragments.insert(
7581            "product-card".to_string(),
7582            FragmentList {
7583                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
7584            },
7585        );
7586
7587        let mut protocol = WebUIProtocol::new(fragments);
7588        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7589        for name in ["app-shell", "cart-panel", "product-card"] {
7590            let comp = protocol.components.entry(name.to_string()).or_default();
7591            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
7592            comp.css = format!(".{name}{{display:block}}");
7593            if name == "cart-panel" {
7594                comp.hydration_mode = StateProjectionMode::Keys as i32;
7595                comp.hydration_keys = vec!["hasItems".to_string()];
7596            }
7597            if name == "product-card" {
7598                comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
7599            }
7600        }
7601
7602        // Render with hasItems=false — product-card should NOT be rendered
7603        let state = test_json!({ "hasItems": false });
7604        let mut writer = TestWriter::new();
7605
7606        let handler = WebUIHandler::with_plugin(|| {
7607            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7608        });
7609        handler
7610            .handle(
7611                &protocol,
7612                &state,
7613                &RenderOptions::new("index.html", "/"),
7614                &mut writer,
7615            )
7616            .unwrap();
7617
7618        let html = writer.get_content();
7619
7620        assert!(
7621            html.contains(r#"<script type="application/json" id="webui-data">"#),
7622            "non-executable SSR metadata should be emitted in the webui-data block: {html}"
7623        );
7624        assert!(
7625            html.contains(r#""state":{"hasItems":false}"#),
7626            "SSR state should live in the JSON data block: {html}"
7627        );
7628        assert!(
7629            html.contains(r#""inventory":"#),
7630            "SSR inventory should live in the JSON data block: {html}"
7631        );
7632        assert!(
7633            !html.contains("window.__webui={\""),
7634            "executable bootstrap must not embed the window.__webui JSON literal: {html}"
7635        );
7636        assert!(
7637            !html.contains(r#"document.getElementById("webui-data")"#),
7638            "SSR must not parse webui-data; client packages own that lazy load: {html}"
7639        );
7640        assert!(
7641            !html.contains("window.__webui=w;"),
7642            "executable bootstrap must not replace existing window.__webui registrations: {html}"
7643        );
7644        assert!(
7645            !html.contains("w.templateFns={\""),
7646            "template function emission must not replace existing templateFns registrations: {html}"
7647        );
7648        assert!(
7649            html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
7650            "template functions should merge into the flat templateFns registry: {html}"
7651        );
7652
7653        // product-card template IS in the output — it's a known component
7654        // whose template must be available for client-side <if> activation.
7655        assert!(
7656            html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
7657            "product-card template should be emitted even when unrendered: {html}"
7658        );
7659
7660        // product-card CSS module IS in the output — reachable components need
7661        // their stylesheet definitions for client-side <if> activation.
7662        assert!(
7663            html.contains(r#""product-card":"data:text/css,"#),
7664            "reachable product-card CSS module importmap should be emitted: {html}"
7665        );
7666
7667        // app-shell and cart-panel SHOULD be in the output (they were rendered)
7668        assert!(
7669            html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
7670            "rendered app-shell template should be emitted: {html}"
7671        );
7672        assert!(
7673            html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
7674            "rendered cart-panel template should be emitted: {html}"
7675        );
7676    }
7677
7678    // ── CSP nonce on CSS module importmap ───────────────────────────
7679    //
7680    // When `RenderOptions::with_nonce(...)` is set, every inline
7681    // `<script type="importmap">` definition emitted during SSR for a
7682    // component CSS module must include `nonce="VALUE"` so strict CSP
7683    // `script-src 'nonce-...'` policies allow it. The without-nonce case
7684    // is already covered by other CSS module tests (e.g.
7685    // `test_css_module_emitted_for_route_components`).
7686
7687    #[test]
7688    fn test_css_module_emits_nonce_attribute_when_nonce_set() {
7689        // Per-component first-render path (`emit_css_module`).
7690        let template = r#"<h1>Dashboard</h1>"#;
7691
7692        let mut fragments = HashMap::new();
7693        fragments.insert(
7694            "index.html".to_string(),
7695            FragmentList {
7696                fragments: vec![
7697                    WebUIFragment::raw("<html><head>".to_string()),
7698                    structural_fragment("head_end"),
7699                    WebUIFragment::raw("</head><body>".to_string()),
7700                    WebUIFragment::route("/", "dash-page"),
7701                    structural_fragment("body_end"),
7702                    WebUIFragment::raw("</body></html>".to_string()),
7703                ],
7704            },
7705        );
7706        fragments.insert(
7707            "dash-page".to_string(),
7708            FragmentList {
7709                fragments: vec![WebUIFragment::raw(template.to_string())],
7710            },
7711        );
7712
7713        let mut protocol = WebUIProtocol::new(fragments);
7714        let comp = protocol
7715            .components
7716            .entry("dash-page".to_string())
7717            .or_default();
7718        comp.css = "h1{font-size:2rem}".to_string();
7719        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
7720        let state = test_json!({});
7721        let mut writer = TestWriter::new();
7722
7723        handle(
7724            &protocol,
7725            &state,
7726            &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
7727            &mut writer,
7728        )
7729        .unwrap();
7730
7731        let html = writer.get_content();
7732
7733        assert!(
7734            html.contains(
7735                r#"<script type="importmap" nonce="test-nonce-123">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
7736            ),
7737            "CSS module importmap tag should include nonce attribute in canonical order: {html}"
7738        );
7739    }
7740
7741    #[test]
7742    fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
7743        // Body-end emission path for reachable-but-unrendered components
7744        // (the second site touched by the patch). Triggered via a false
7745        // `<if>` block under hydration; requires the WebUI plugin so the
7746        // body_end hook executes.
7747        let mut fragments = HashMap::new();
7748        fragments.insert(
7749            "index.html".to_string(),
7750            FragmentList {
7751                fragments: vec![
7752                    WebUIFragment::raw("<html><head>".to_string()),
7753                    structural_fragment("head_end"),
7754                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
7755                    WebUIFragment::component("app-shell"),
7756                    WebUIFragment::raw("</app-shell>".to_string()),
7757                    structural_fragment("body_end"),
7758                    WebUIFragment::raw("</body></html>".to_string()),
7759                ],
7760            },
7761        );
7762        fragments.insert(
7763            "app-shell".to_string(),
7764            FragmentList {
7765                fragments: vec![WebUIFragment::if_cond(
7766                    ConditionExpr::identifier("hasItems"),
7767                    "cart-items",
7768                )],
7769            },
7770        );
7771        fragments.insert(
7772            "cart-items".to_string(),
7773            FragmentList {
7774                fragments: vec![WebUIFragment::component("product-card")],
7775            },
7776        );
7777        fragments.insert(
7778            "product-card".to_string(),
7779            FragmentList {
7780                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
7781            },
7782        );
7783
7784        let mut protocol = WebUIProtocol::new(fragments);
7785        for name in ["app-shell", "product-card"] {
7786            let comp = protocol.components.entry(name.to_string()).or_default();
7787            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
7788            comp.css = format!(".{name}{{display:block}}");
7789        }
7790
7791        // Render with hasItems=false so product-card is reachable but not
7792        // rendered, forcing its CSS module emission through the body_end path.
7793        let state = test_json!({ "hasItems": false });
7794        let mut writer = TestWriter::new();
7795
7796        let handler = WebUIHandler::with_plugin(|| {
7797            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7798        });
7799        handler
7800            .handle(
7801                &protocol,
7802                &state,
7803                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
7804                &mut writer,
7805            )
7806            .unwrap();
7807
7808        let html = writer.get_content();
7809
7810        assert!(
7811            html.contains(
7812                r#"<script type="importmap" nonce="test-nonce-123">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
7813            ),
7814            "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
7815        );
7816    }
7817
7818    #[test]
7819    fn projected_state_excludes_non_hydration_keys() -> Result<()> {
7820        let mut fragments = HashMap::new();
7821        fragments.insert(
7822            "index.html".to_string(),
7823            FragmentList {
7824                fragments: vec![
7825                    WebUIFragment::raw("<html><body><style>".to_string()),
7826                    WebUIFragment::signal("tokens.light", true),
7827                    WebUIFragment::raw("</style>".to_string()),
7828                    structural_fragment("body_end"),
7829                    WebUIFragment::raw("</body></html>".to_string()),
7830                ],
7831            },
7832        );
7833        fragments.insert(
7834            "app-shell".to_string(),
7835            FragmentList {
7836                fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
7837            },
7838        );
7839        let index_fragments = fragments
7840            .get_mut("index.html")
7841            .expect("index fixture should exist");
7842        index_fragments
7843            .fragments
7844            .insert(1, WebUIFragment::component("app-shell"));
7845        let mut protocol = WebUIProtocol::new(fragments);
7846        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7847        // Only `name` is a hydration key. `tokens` is a server-only field
7848        // (used above to resolve SSR CSS variables) and is NOT in the component
7849        // hydration keys,
7850        // so projection MUST keep it out of the client state block.
7851        protocol.components.insert(
7852            "app-shell".to_string(),
7853            webui_protocol::ComponentData {
7854                hydration_mode: StateProjectionMode::Keys as i32,
7855                hydration_keys: vec!["name".to_string()],
7856                ..Default::default()
7857            },
7858        );
7859        let state = test_json!({
7860            "name": "Alice",
7861            "tokens": {
7862                "light": "--color-brand: red;"
7863            }
7864        });
7865        let mut writer = TestWriter::new();
7866        let handler = WebUIHandler::with_plugin(|| {
7867            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7868        });
7869        handler.handle(
7870            &protocol,
7871            &state,
7872            &RenderOptions::new("index.html", "/"),
7873            &mut writer,
7874        )?;
7875        let output = writer.get_content();
7876
7877        // SSR still reads `tokens` to resolve the inline <style>...
7878        assert!(output.contains("--color-brand: red;"));
7879        // ...but only the hydration key reaches the client state.
7880        assert!(output.contains(r#""name":"Alice""#));
7881        assert!(!output.contains(r#""tokens""#));
7882        Ok(())
7883    }
7884
7885    #[test]
7886    fn full_initial_strategy_preserves_complete_state() -> Result<()> {
7887        let mut fragments = HashMap::new();
7888        fragments.insert(
7889            "index.html".to_string(),
7890            FragmentList {
7891                fragments: vec![
7892                    WebUIFragment::raw("<html><body>"),
7893                    structural_fragment("body_end"),
7894                    WebUIFragment::raw("</body></html>"),
7895                ],
7896            },
7897        );
7898        let protocol = WebUIProtocol::new(fragments);
7899        let state = test_json!({
7900            "client": "visible",
7901            "serverOnly": "also preserved",
7902        });
7903        let handler = WebUIHandler::with_plugin(|| {
7904            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7905        });
7906        let mut writer = TestWriter::new();
7907        handler.handle(
7908            &protocol,
7909            &state,
7910            &RenderOptions::new("index.html", "/"),
7911            &mut writer,
7912        )?;
7913        let output = writer.get_content();
7914        assert!(output.contains(r#""client":"visible""#));
7915        assert!(output.contains(r#""serverOnly":"also preserved""#));
7916        Ok(())
7917    }
7918
7919    #[test]
7920    fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
7921        let mut fragments = HashMap::new();
7922        fragments.insert(
7923            "index.html".to_string(),
7924            FragmentList {
7925                fragments: vec![
7926                    WebUIFragment::raw("<html><body>"),
7927                    WebUIFragment::component("app-shell"),
7928                    structural_fragment("body_end"),
7929                    WebUIFragment::raw("</body></html>"),
7930                ],
7931            },
7932        );
7933        fragments.insert(
7934            "app-shell".to_string(),
7935            FragmentList {
7936                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
7937            },
7938        );
7939        let mut protocol = WebUIProtocol::new(fragments);
7940        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7941        protocol.components.insert(
7942            "app-shell".to_string(),
7943            webui_protocol::ComponentData {
7944                hydration_mode: StateProjectionMode::All as i32,
7945                ..Default::default()
7946            },
7947        );
7948        let state = test_json!({
7949            "known": "value",
7950            "possiblyInherited": "must not be dropped",
7951        });
7952        let handler = WebUIHandler::with_plugin(|| {
7953            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7954        });
7955        let mut writer = TestWriter::new();
7956        handler.handle(
7957            &protocol,
7958            &state,
7959            &RenderOptions::new("index.html", "/"),
7960            &mut writer,
7961        )?;
7962        let output = writer.get_content();
7963        assert!(output.contains(r#""known":"value""#));
7964        assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
7965        Ok(())
7966    }
7967
7968    #[test]
7969    fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
7970        let mut fragments = HashMap::new();
7971        fragments.insert(
7972            "index.html".to_string(),
7973            FragmentList {
7974                fragments: vec![
7975                    WebUIFragment::raw("<html><body>"),
7976                    WebUIFragment::component("app-shell"),
7977                    structural_fragment("body_end"),
7978                    WebUIFragment::raw("</body></html>"),
7979                ],
7980            },
7981        );
7982        fragments.insert(
7983            "app-shell".to_string(),
7984            FragmentList {
7985                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
7986            },
7987        );
7988        let mut protocol = WebUIProtocol::new(fragments);
7989        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7990        let state = test_json!({
7991            "known": "value",
7992            "serverOnly": "must not be dropped",
7993        });
7994        let handler = WebUIHandler::with_plugin(|| {
7995            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7996        });
7997        let mut writer = TestWriter::new();
7998        handler.handle(
7999            &protocol,
8000            &state,
8001            &RenderOptions::new("index.html", "/"),
8002            &mut writer,
8003        )?;
8004        let output = writer.get_content();
8005        assert!(output.contains(r#""known":"value""#));
8006        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
8007        Ok(())
8008    }
8009
8010    #[test]
8011    fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
8012        let mut fragments = HashMap::new();
8013        fragments.insert(
8014            "index.html".to_string(),
8015            FragmentList {
8016                fragments: vec![
8017                    WebUIFragment::raw("<html><body>"),
8018                    WebUIFragment::component("app-shell"),
8019                    structural_fragment("body_end"),
8020                    WebUIFragment::raw("</body></html>"),
8021                ],
8022            },
8023        );
8024        fragments.insert(
8025            "app-shell".to_string(),
8026            FragmentList {
8027                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
8028            },
8029        );
8030        let mut protocol = WebUIProtocol::new(fragments);
8031        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
8032        protocol.components.insert(
8033            "app-shell".to_string(),
8034            webui_protocol::ComponentData {
8035                hydration_mode: i32::MAX,
8036                ..Default::default()
8037            },
8038        );
8039        let state = test_json!({
8040            "known": "value",
8041            "serverOnly": "must not be dropped",
8042        });
8043        let handler = WebUIHandler::with_plugin(|| {
8044            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8045        });
8046        let mut writer = TestWriter::new();
8047        handler.handle(
8048            &protocol,
8049            &state,
8050            &RenderOptions::new("index.html", "/"),
8051            &mut writer,
8052        )?;
8053        let output = writer.get_content();
8054        assert!(output.contains(r#""known":"value""#));
8055        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
8056        Ok(())
8057    }
8058
8059    #[test]
8060    fn legacy_navigation_keys_with_default_mode_remain_keyed() {
8061        let mut protocol = WebUIProtocol::new(HashMap::new());
8062        protocol.components.insert(
8063            "app-shell".to_string(),
8064            webui_protocol::ComponentData {
8065                navigation_keys: vec!["selected".to_string()],
8066                ..Default::default()
8067            },
8068        );
8069
8070        match collect_navigation_state(&protocol, ["app-shell"]) {
8071            StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
8072            StateSelection::Full | StateSelection::BorrowedKeys(_) => {
8073                panic!("legacy navigation keys should remain owned and projected")
8074            }
8075        }
8076    }
8077
8078    #[test]
8079    fn legacy_navigation_without_projection_metadata_preserves_full_state() {
8080        let mut protocol = WebUIProtocol::new(HashMap::new());
8081        protocol.components.insert(
8082            "app-shell".to_string(),
8083            webui_protocol::ComponentData::default(),
8084        );
8085
8086        assert!(matches!(
8087            collect_navigation_state(&protocol, ["app-shell"]),
8088            StateSelection::Full
8089        ));
8090    }
8091
8092    #[test]
8093    fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
8094        let mut fragments = HashMap::new();
8095        fragments.insert(
8096            "index.html".to_string(),
8097            FragmentList {
8098                fragments: vec![
8099                    WebUIFragment::raw("<html><body>".to_string()),
8100                    structural_fragment("body_end"),
8101                    WebUIFragment::raw("</body></html>".to_string()),
8102                ],
8103            },
8104        );
8105        let mut protocol = WebUIProtocol::new(fragments);
8106        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
8107        let state = test_json!({
8108            "title": "Legacy state",
8109            "serverOnly": "preserved",
8110        });
8111        let handler = WebUIHandler::with_plugin(|| {
8112            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8113        });
8114
8115        let mut writer = TestWriter::new();
8116        handler.handle(
8117            &protocol,
8118            &state,
8119            &RenderOptions::new("index.html", "/"),
8120            &mut writer,
8121        )?;
8122        assert!(writer.get_content().contains(r#""state":{}"#));
8123        assert!(!writer.get_content().contains("Legacy state"));
8124        assert!(!writer.get_content().contains("preserved"));
8125        Ok(())
8126    }
8127
8128    #[test]
8129    fn scriptless_component_state_is_navigation_only() -> Result<()> {
8130        let mut fragments = HashMap::new();
8131        fragments.insert(
8132            "index.html".to_string(),
8133            FragmentList {
8134                fragments: vec![
8135                    WebUIFragment::raw("<html><body>"),
8136                    WebUIFragment::component("items-page"),
8137                    structural_fragment("body_end"),
8138                    WebUIFragment::raw("</body></html>"),
8139                ],
8140            },
8141        );
8142        fragments.insert(
8143            "items-page".to_string(),
8144            FragmentList {
8145                fragments: vec![WebUIFragment::raw("<p>Items</p>")],
8146            },
8147        );
8148        let mut protocol = WebUIProtocol::new(fragments);
8149        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
8150        protocol.components.insert(
8151            "items-page".to_string(),
8152            webui_protocol::ComponentData {
8153                template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
8154                navigation_mode: Some(StateProjectionMode::Keys as i32),
8155                navigation_keys: vec!["items".into()],
8156                ..Default::default()
8157            },
8158        );
8159        let state = test_json!({
8160            "items": ["STATE_SENTINEL"],
8161            "serverOnly": "SECRET_SENTINEL",
8162        });
8163        let handler = WebUIHandler::with_plugin(|| {
8164            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8165        });
8166        let mut writer = TestWriter::new();
8167
8168        handler.handle(
8169            &protocol,
8170            &state,
8171            &RenderOptions::new("index.html", "/"),
8172            &mut writer,
8173        )?;
8174
8175        let output = writer.get_content();
8176        assert!(output.contains(r#""state":{}"#));
8177        assert!(!output.contains("STATE_SENTINEL"));
8178        assert!(!output.contains("SECRET_SENTINEL"));
8179        Ok(())
8180    }
8181
8182    #[test]
8183    fn write_selected_state_projects_and_escapes() {
8184        // `keep` is in the sorted key set and its value contains a `</` that
8185        // must be escaped; `drop` is absent and must be projected out.
8186        let state = test_json!({
8187            "drop": "secret",
8188            "keep": "</script><b>"
8189        });
8190        let keys = ["keep"];
8191        let selection = StateSelection::Keys(keys.to_vec());
8192        let mut sink = TestWriter::new();
8193        let mut scratch = Vec::new();
8194        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
8195        assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
8196    }
8197
8198    #[test]
8199    fn write_selected_state_non_object_projection_emits_empty_object() {
8200        let state = test_json!("scalar state has nothing hydratable");
8201        let keys: [&str; 0] = [];
8202        let selection = StateSelection::Keys(keys.to_vec());
8203        let mut sink = TestWriter::new();
8204        let mut scratch = Vec::new();
8205        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
8206        assert_eq!(sink.get_content(), "{}");
8207    }
8208
8209    #[test]
8210    fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
8211        let state = test_json!({
8212            "keptA": 1,
8213            "keptB": 2,
8214            "serverOnlyA": 3,
8215            "serverOnlyB": 4,
8216        });
8217        let keys = ["keptA", "keptA", "keptB", "missing"];
8218        let selection = StateSelection::Keys(keys.to_vec());
8219        let mut sink = TestWriter::new();
8220        let mut scratch = Vec::new();
8221        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
8222        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
8223    }
8224
8225    #[test]
8226    fn write_selected_state_map_first_matches_schema_first_output() {
8227        let state = test_json!({
8228            "keptA": 1,
8229            "keptB": 2,
8230        });
8231        let keys = ["keptA", "keptB", "missingA", "missingB"];
8232        let selection = StateSelection::Keys(keys.to_vec());
8233        let mut sink = TestWriter::new();
8234        let mut scratch = Vec::new();
8235        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
8236        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
8237    }
8238
8239    #[test]
8240    fn write_selected_state_full_preserves_and_escapes_state() {
8241        let state = test_json!({
8242            "serverOnly": "</script><b>",
8243            "value": 42,
8244        });
8245        let mut sink = TestWriter::new();
8246        let mut scratch = Vec::new();
8247        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
8248        assert_eq!(
8249            sink.get_content(),
8250            r#"{"serverOnly":"<\/script><b>","value":42}"#
8251        );
8252    }
8253
8254    #[test]
8255    fn json_scratch_capacity_reused_across_checkpoint_payloads() {
8256        // Simulates several checkpoint bootstraps serialized in one render: a
8257        // single request-local buffer must grow once for the largest payload and
8258        // then be reused for subsequent payloads without reallocating. This is
8259        // the deterministic seam behind the streaming path's allocation gate —
8260        // `write_script_safe_json` is `pub(crate)`, so no public API is exposed.
8261        let mut sink = TestWriter::new();
8262        let mut scratch: Vec<u8> = Vec::new();
8263        // No allocation until serialization actually needs it.
8264        assert_eq!(scratch.capacity(), 0);
8265
8266        // The largest payload first establishes the high-water capacity.
8267        let mut large = serde_json::Map::new();
8268        for index in 0..64 {
8269            large.insert(
8270                format!("component_{index:04}"),
8271                Value::String("x".repeat(48)),
8272            );
8273        }
8274        let large = Value::Object(large);
8275        write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
8276        let high_water = scratch.capacity();
8277        assert!(high_water > 0);
8278
8279        // Subsequent smaller checkpoint payloads reuse the same buffer: capacity
8280        // never grows (no per-checkpoint allocation) and never shrinks.
8281        for seq in 0..8 {
8282            let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
8283            write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
8284            assert_eq!(
8285                scratch.capacity(),
8286                high_water,
8287                "scratch reallocated on checkpoint {seq}"
8288            );
8289        }
8290    }
8291
8292    #[test]
8293    fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
8294        let mut fragments = HashMap::new();
8295        fragments.insert(
8296            "index.html".to_string(),
8297            FragmentList {
8298                fragments: vec![
8299                    WebUIFragment::raw("<html><body>"),
8300                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
8301                        path: "/".to_string(),
8302                        fragment_id: "home-page".to_string(),
8303                        exact: true,
8304                        ..Default::default()
8305                    }),
8306                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
8307                        path: "/admin".to_string(),
8308                        fragment_id: "admin-page".to_string(),
8309                        exact: true,
8310                        ..Default::default()
8311                    }),
8312                    structural_fragment("body_end"),
8313                    WebUIFragment::raw("</body></html>"),
8314                ],
8315            },
8316        );
8317        fragments.insert(
8318            "home-page".to_string(),
8319            FragmentList {
8320                fragments: vec![WebUIFragment::raw("<p>Home</p>")],
8321            },
8322        );
8323        fragments.insert(
8324            "admin-page".to_string(),
8325            FragmentList {
8326                fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
8327            },
8328        );
8329
8330        let mut protocol = WebUIProtocol::new(fragments);
8331        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
8332        protocol.components.insert(
8333            "home-page".to_string(),
8334            webui_protocol::ComponentData {
8335                template_json: "{}".to_string(),
8336                hydration_mode: StateProjectionMode::Keys as i32,
8337                hydration_keys: vec!["homeTitle".to_string()],
8338                ..Default::default()
8339            },
8340        );
8341        protocol.components.insert(
8342            "admin-page".to_string(),
8343            webui_protocol::ComponentData {
8344                template_json: "{}".to_string(),
8345                hydration_mode: StateProjectionMode::Keys as i32,
8346                hydration_keys: vec!["adminToken".to_string()],
8347                ..Default::default()
8348            },
8349        );
8350        let state = test_json!({
8351            "homeTitle": "Welcome",
8352            "adminToken": "TOP_SECRET_SENTINEL",
8353        });
8354        let handler = WebUIHandler::with_plugin(|| {
8355            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8356        });
8357        let mut writer = TestWriter::new();
8358        handler.handle(
8359            &protocol,
8360            &state,
8361            &RenderOptions::new("index.html", "/"),
8362            &mut writer,
8363        )?;
8364        let output = writer.get_content();
8365        assert!(output.contains(r#""homeTitle":"Welcome""#));
8366        assert!(!output.contains("TOP_SECRET_SENTINEL"));
8367        assert!(!output.contains(r#""adminToken""#));
8368        Ok(())
8369    }
8370
8371    #[test]
8372    fn test_component_attr_name_aria() {
8373        // component_attr_name correctly maps ARIA attributes via the shared table
8374        assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
8375        assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
8376        assert_eq!(
8377            component_attr_name("aria-activedescendant"),
8378            "ariaActiveDescendant"
8379        );
8380        assert_eq!(component_attr_name("aria-label"), "ariaLabel");
8381        assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
8382    }
8383
8384    #[test]
8385    fn test_component_attr_name_html_global() {
8386        assert_eq!(component_attr_name("readonly"), "readOnly");
8387        assert_eq!(component_attr_name("tabindex"), "tabIndex");
8388        assert_eq!(component_attr_name("accesskey"), "accessKey");
8389        assert_eq!(component_attr_name("contenteditable"), "contentEditable");
8390        assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
8391        assert_eq!(component_attr_name("inputmode"), "inputMode");
8392        assert_eq!(component_attr_name("maxlength"), "maxLength");
8393        assert_eq!(component_attr_name("minlength"), "minLength");
8394        assert_eq!(component_attr_name("novalidate"), "noValidate");
8395        assert_eq!(component_attr_name("formaction"), "formAction");
8396        assert_eq!(component_attr_name("ismap"), "isMap");
8397        assert_eq!(component_attr_name("usemap"), "useMap");
8398    }
8399
8400    #[test]
8401    fn test_component_attr_name_strips_colon() {
8402        assert_eq!(component_attr_name(":readonly"), "readOnly");
8403        assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
8404        assert_eq!(component_attr_name(":data-title"), "dataTitle");
8405    }
8406
8407    #[test]
8408    fn test_component_attr_name_regular() {
8409        assert_eq!(component_attr_name("data-title"), "dataTitle");
8410        assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
8411        assert_eq!(component_attr_name("simple"), "simple");
8412    }
8413
8414    // ── allowed_query SSR emission tests ─────────────────────────────
8415
8416    fn make_query_route_protocol() -> WebUIProtocol {
8417        use webui_protocol::WebUiFragmentRoute;
8418
8419        let mut fragments = HashMap::new();
8420
8421        fragments.insert(
8422            "index.html".to_string(),
8423            FragmentList {
8424                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
8425                    path: "/".into(),
8426                    fragment_id: "app-shell".into(),
8427                    exact: false,
8428                    children: vec![
8429                        WebUiFragmentRoute {
8430                            path: "compose".into(),
8431                            fragment_id: "compose-page".into(),
8432                            exact: true,
8433                            allowed_query: "action,to,subject".into(),
8434                            keep_alive: false,
8435                            ..Default::default()
8436                        },
8437                        WebUiFragmentRoute {
8438                            path: "settings".into(),
8439                            fragment_id: "settings-page".into(),
8440                            exact: true,
8441                            keep_alive: false,
8442                            ..Default::default()
8443                        },
8444                    ],
8445                    keep_alive: false,
8446                    ..Default::default()
8447                })],
8448            },
8449        );
8450
8451        fragments.insert(
8452            "app-shell".to_string(),
8453            FragmentList {
8454                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
8455            },
8456        );
8457        fragments.insert(
8458            "compose-page".to_string(),
8459            FragmentList {
8460                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
8461            },
8462        );
8463        fragments.insert(
8464            "settings-page".to_string(),
8465            FragmentList {
8466                fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
8467            },
8468        );
8469
8470        WebUIProtocol::new(fragments)
8471    }
8472
8473    #[test]
8474    fn test_matched_route_omits_query_attr_from_dom() {
8475        let protocol = make_query_route_protocol();
8476        let state = test_json!({});
8477        let handler = WebUIHandler::new();
8478        let mut writer = TestWriter::new();
8479
8480        handler
8481            .handle(
8482                &protocol,
8483                &state,
8484                &RenderOptions::new("index.html", "/compose"),
8485                &mut writer,
8486            )
8487            .expect("render failed");
8488
8489        let html = writer.get_content();
8490        // query attr is no longer in DOM — it's in the SSR chain JSON instead
8491        assert!(
8492            !html.contains(r#"query="action,to,subject""#),
8493            "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
8494        );
8495    }
8496
8497    #[test]
8498    fn test_nonmatched_route_omits_query_attr_from_dom() {
8499        let protocol = make_query_route_protocol();
8500        let state = test_json!({});
8501        let handler = WebUIHandler::new();
8502        let mut writer = TestWriter::new();
8503
8504        handler
8505            .handle(
8506                &protocol,
8507                &state,
8508                &RenderOptions::new("index.html", "/settings"),
8509                &mut writer,
8510            )
8511            .expect("render failed");
8512
8513        let html = writer.get_content();
8514        // query attr should not be on hidden siblings either
8515        assert!(
8516            !html.contains(r#"query="#),
8517            "hidden route should not have query attr: {html}"
8518        );
8519    }
8520
8521    #[test]
8522    fn test_route_without_query_has_no_query_attr() {
8523        let protocol = make_query_route_protocol();
8524        let state = test_json!({});
8525        let handler = WebUIHandler::new();
8526        let mut writer = TestWriter::new();
8527
8528        handler
8529            .handle(
8530                &protocol,
8531                &state,
8532                &RenderOptions::new("index.html", "/settings"),
8533                &mut writer,
8534            )
8535            .expect("render failed");
8536
8537        let html = writer.get_content();
8538        // Find the settings route element and verify it has no query attr
8539        let settings_idx = html
8540            .find(r#"component="settings-page""#)
8541            .expect("settings route should exist");
8542        let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
8543        assert!(
8544            !settings_tag.contains("query="),
8545            "route without allowed_query should not emit query attr: {settings_tag}"
8546        );
8547    }
8548
8549    // ── Per-render head_inject / body_inject (replaces the byte-scanner
8550    //    InjectingStreamingWriter approach with structural signal-based
8551    //    injection) ───────────────────────────────────────────────────
8552
8553    fn build_head_body_protocol() -> WebUIProtocol {
8554        let mut fragments = HashMap::new();
8555        fragments.insert(
8556            "index.html".to_string(),
8557            FragmentList {
8558                fragments: vec![
8559                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
8560                    structural_fragment("head_end"),
8561                    WebUIFragment::raw("</head><body>hello".to_string()),
8562                    structural_fragment("body_end"),
8563                    WebUIFragment::raw("</body></html>".to_string()),
8564                ],
8565            },
8566        );
8567        WebUIProtocol::new(fragments)
8568    }
8569
8570    #[test]
8571    fn head_inject_emits_at_head_end_boundary() {
8572        let protocol = build_head_body_protocol();
8573        let state = test_json!({});
8574        let mut writer = TestWriter::new();
8575        let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
8576        handle(&protocol, &state, &opts, &mut writer).unwrap();
8577        let html = writer.get_content();
8578        // The inject must appear immediately before `</head>`.
8579        let inject_idx = html
8580            .find("<link rel=preload>")
8581            .expect("inject HTML missing");
8582        let head_close_idx = html.find("</head>").expect("</head> missing");
8583        assert!(
8584            inject_idx < head_close_idx,
8585            "head_inject must appear before </head>: {html}"
8586        );
8587        // No duplicate.
8588        assert_eq!(html.matches("<link rel=preload>").count(), 1);
8589    }
8590
8591    #[test]
8592    fn body_inject_emits_at_body_end_boundary() {
8593        let protocol = build_head_body_protocol();
8594        let state = test_json!({});
8595        let mut writer = TestWriter::new();
8596        let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
8597        handle(&protocol, &state, &opts, &mut writer).unwrap();
8598        let html = writer.get_content();
8599        let inject_idx = html
8600            .find("<script>lr</script>")
8601            .expect("inject HTML missing");
8602        let body_close_idx = html.find("</body>").expect("</body> missing");
8603        assert!(
8604            inject_idx < body_close_idx,
8605            "body_inject must appear before </body>: {html}"
8606        );
8607        assert_eq!(html.matches("<script>lr</script>").count(), 1);
8608    }
8609
8610    // ── Reserved `$webui` state inject namespace ──────────────────────
8611
8612    /// Protocol carrying all three structural boundaries, so `body_start`
8613    /// placement can be asserted alongside `head_end` / `body_end`.
8614    fn build_all_boundaries_protocol() -> WebUIProtocol {
8615        let mut fragments = HashMap::new();
8616        fragments.insert(
8617            "index.html".to_string(),
8618            FragmentList {
8619                fragments: vec![
8620                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
8621                    structural_fragment("head_end"),
8622                    WebUIFragment::raw("</head><body>".to_string()),
8623                    structural_fragment("body_start"),
8624                    WebUIFragment::raw("hello".to_string()),
8625                    structural_fragment("body_end"),
8626                    WebUIFragment::raw("</body></html>".to_string()),
8627                ],
8628            },
8629        );
8630        WebUIProtocol::new(fragments)
8631    }
8632
8633    fn state_inject_options<'a>() -> RenderOptions<'a> {
8634        RenderOptions::new("index.html", "/")
8635    }
8636
8637    fn render_with(protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>) -> String {
8638        let mut writer = TestWriter::new();
8639        handle(protocol, state, options, &mut writer).unwrap();
8640        writer.get_content().to_string()
8641    }
8642
8643    #[test]
8644    fn state_inject_emits_at_every_structural_boundary() {
8645        let protocol = build_all_boundaries_protocol();
8646        let state = test_json!({
8647            "$webui": {
8648                "headEnd": "<meta name=he>",
8649                "bodyStart": "<span id=bs></span>",
8650                "bodyEnd": "<script>be</script>",
8651            }
8652        });
8653        let html = render_with(&protocol, &state, &state_inject_options());
8654
8655        let head_end = html.find("<meta name=he>").expect("headEnd missing");
8656        let head_close = html.find("</head>").expect("</head> missing");
8657        let body_open = html.find("<body>").expect("<body> missing");
8658        let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
8659        let hello = html.find("hello").expect("body content missing");
8660        let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
8661        let body_close = html.find("</body>").expect("</body> missing");
8662
8663        assert!(
8664            head_end < head_close,
8665            "headEnd must precede </head>: {html}"
8666        );
8667        assert!(
8668            body_open < body_start && body_start < hello,
8669            "bodyStart must sit immediately after <body>: {html}"
8670        );
8671        assert!(
8672            hello < body_end && body_end < body_close,
8673            "bodyEnd must precede </body>: {html}"
8674        );
8675
8676        for needle in [
8677            "<meta name=he>",
8678            "<span id=bs></span>",
8679            "<script>be</script>",
8680        ] {
8681            assert_eq!(html.matches(needle).count(), 1, "duplicated {needle}");
8682        }
8683    }
8684
8685    #[test]
8686    fn state_inject_follows_render_options_inject() {
8687        let protocol = build_all_boundaries_protocol();
8688        let state = test_json!({
8689            "$webui": { "headEnd": "<!--state-he-->", "bodyEnd": "<!--state-be-->" }
8690        });
8691        let options = RenderOptions::new("index.html", "/")
8692            .with_head_inject("<!--opt-he-->")
8693            .with_body_inject("<!--opt-be-->");
8694        let html = render_with(&protocol, &state, &options);
8695
8696        let opt_he = html.find("<!--opt-he-->").expect("option headEnd missing");
8697        let state_he = html.find("<!--state-he-->").expect("state headEnd missing");
8698        let opt_be = html.find("<!--opt-be-->").expect("option bodyEnd missing");
8699        let state_be = html.find("<!--state-be-->").expect("state bodyEnd missing");
8700
8701        assert!(
8702            opt_he < state_he,
8703            "RenderOptions head_inject must precede the state-supplied value: {html}"
8704        );
8705        assert!(
8706            opt_be < state_be,
8707            "RenderOptions body_inject must precede the state-supplied value: {html}"
8708        );
8709    }
8710
8711    #[test]
8712    fn malformed_state_inject_values_are_inert() {
8713        let protocol = build_all_boundaries_protocol();
8714        // Absent key, wrong container type, and per-member null / empty /
8715        // non-string values must all render without output and without error.
8716        for state in [
8717            test_json!({}),
8718            test_json!({ "$webui": "not-an-object" }),
8719            test_json!({ "$webui": [] }),
8720            test_json!({ "$webui": null }),
8721            test_json!({ "$webui": { "headEnd": null, "bodyStart": "", "bodyEnd": 42 } }),
8722            test_json!({ "$webui": { "unknownMember": "<b>x</b>" } }),
8723        ] {
8724            let html = render_with(&protocol, &state, &state_inject_options());
8725            assert!(
8726                html.contains("<html><head><title>x</title></head><body>hello</body></html>"),
8727                "malformed reserved state must render the document unchanged: {html}"
8728            );
8729            assert!(!html.contains("<b>x</b>"), "unknown member leaked: {html}");
8730            assert!(!html.contains("42"), "non-string member leaked: {html}");
8731        }
8732    }
8733
8734    #[test]
8735    fn state_inject_never_reaches_the_hydration_payload() {
8736        let protocol = build_all_boundaries_protocol();
8737        let state = test_json!({
8738            "visible": "keep",
8739            "$webui": { "bodyEnd": "<script>secret</script>" }
8740        });
8741        let handler = WebUIHandler::with_plugin(|| {
8742            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8743        });
8744        let runtime = Protocol::new(protocol.clone());
8745        let mut writer = TestWriter::new();
8746        handler
8747            .render(&runtime, &state, &state_inject_options(), &mut writer)
8748            .unwrap();
8749        let html = writer.get_content();
8750
8751        let data_start = html
8752            .find(r#"<script type="application/json" id="webui-data""#)
8753            .expect("hydration block missing");
8754        let data_end = html[data_start..]
8755            .find("</script>")
8756            .map(|offset| data_start + offset)
8757            .expect("hydration block never closes");
8758        let payload = &html[data_start..data_end];
8759        assert!(
8760            !payload.contains("$webui"),
8761            "reserved key must be stripped from the hydration payload: {payload}"
8762        );
8763        assert!(
8764            payload.contains("visible"),
8765            "ordinary state must survive the filter: {payload}"
8766        );
8767        // Per the documented precedence the injected HTML is emitted after
8768        // the built-in hydration block, and still before `</body>`.
8769        let inject = html
8770            .find("<script>secret</script>")
8771            .expect("inject missing");
8772        let body_close = html.find("</body>").expect("</body> missing");
8773        assert!(
8774            data_end < inject && inject < body_close,
8775            "state bodyEnd must follow the hydration block and precede </body>: {html}"
8776        );
8777    }
8778
8779    #[test]
8780    fn projected_hydration_strips_reserved_state_inject_key() {
8781        let mut fragments = HashMap::new();
8782        fragments.insert(
8783            "index.html".to_string(),
8784            FragmentList {
8785                fragments: vec![
8786                    WebUIFragment::raw("<html><body>"),
8787                    WebUIFragment::component("app-shell"),
8788                    structural_fragment("body_end"),
8789                    WebUIFragment::raw("</body></html>"),
8790                ],
8791            },
8792        );
8793        fragments.insert(
8794            "app-shell".to_string(),
8795            FragmentList {
8796                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
8797            },
8798        );
8799        let mut document = WebUIProtocol::new(fragments);
8800        document.initial_state_strategy = InitialStateStrategy::Components as i32;
8801        document.components.insert(
8802            "app-shell".to_string(),
8803            webui_protocol::ComponentData {
8804                hydration_mode: StateProjectionMode::Keys as i32,
8805                hydration_keys: vec![STATE_INJECT_KEY.to_string(), "visible".to_string()],
8806                ..Default::default()
8807            },
8808        );
8809        let state = test_json!({
8810            "$webui": { "bodyEnd": "<script>secret</script>" },
8811            "serverOnly": "drop",
8812            "visible": "keep",
8813        });
8814        let handler = WebUIHandler::with_plugin(|| {
8815            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8816        });
8817        let mut writer = TestWriter::new();
8818
8819        handler
8820            .render(
8821                &Protocol::new(document),
8822                &state,
8823                &RenderOptions::new("index.html", "/"),
8824                &mut writer,
8825            )
8826            .unwrap();
8827        let html = writer.get_content();
8828        let data_start = html
8829            .find(r#"<script type="application/json" id="webui-data""#)
8830            .expect("hydration block missing");
8831        let data_end = html[data_start..]
8832            .find("</script>")
8833            .map(|offset| data_start + offset)
8834            .expect("hydration block never closes");
8835        let payload = &html[data_start..data_end];
8836
8837        assert!(
8838            !payload.contains(STATE_INJECT_KEY),
8839            "reserved key leaked: {payload}"
8840        );
8841        assert!(payload.contains(r#""visible":"keep""#), "{payload}");
8842        assert!(!payload.contains("serverOnly"), "{payload}");
8843        assert!(html.contains("<script>secret</script>"), "{html}");
8844    }
8845
8846    #[test]
8847    fn write_selected_state_strips_reserved_key_from_full_state() {
8848        let state = test_json!({ "a": 1, "$webui": { "bodyEnd": "<b>x</b>" }, "z": 2 });
8849        let mut sink = TestWriter::new();
8850        let mut scratch = Vec::new();
8851        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
8852        let json = sink.get_content();
8853        assert!(!json.contains("$webui"), "reserved key leaked: {json}");
8854        assert!(
8855            json.contains("\"a\":1") && json.contains("\"z\":2"),
8856            "{json}"
8857        );
8858    }
8859
8860    #[test]
8861    fn write_selected_state_strips_reserved_key_from_borrowed_projection() {
8862        let state = test_json!({
8863            "$webui": { "bodyEnd": "<b>x</b>" },
8864            "keep": 1,
8865        });
8866        let keys = [STATE_INJECT_KEY, "keep", "missing"];
8867        let mut sink = TestWriter::new();
8868        let mut scratch = Vec::new();
8869
8870        write_selected_state(
8871            &mut sink,
8872            &mut scratch,
8873            &state,
8874            &StateSelection::BorrowedKeys(&keys),
8875        )
8876        .unwrap();
8877
8878        assert_eq!(sink.get_content(), r#"{"keep":1}"#);
8879    }
8880
8881    #[test]
8882    fn write_selected_state_full_is_unchanged_without_reserved_key() {
8883        let state = test_json!({ "a": 1, "z": 2 });
8884        let mut sink = TestWriter::new();
8885        let mut scratch = Vec::new();
8886        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
8887        assert_eq!(sink.get_content(), r#"{"a":1,"z":2}"#);
8888    }
8889
8890    #[test]
8891    fn body_start_hook_dedupes_on_malformed_protocol() {
8892        let mut fragments = HashMap::new();
8893        fragments.insert(
8894            "index.html".to_string(),
8895            FragmentList {
8896                fragments: vec![
8897                    WebUIFragment::raw("<html><head></head><body>".to_string()),
8898                    structural_fragment("body_start"),
8899                    structural_fragment("body_start"),
8900                    WebUIFragment::raw("</body></html>".to_string()),
8901                ],
8902            },
8903        );
8904        let protocol = WebUIProtocol::new(fragments);
8905        let state = test_json!({ "$webui": { "bodyStart": "<i>once</i>" } });
8906        let html = render_with(&protocol, &state, &state_inject_options());
8907        assert_eq!(
8908            html.matches("<i>once</i>").count(),
8909            1,
8910            "duplicate body_start signals must not duplicate the inject: {html}"
8911        );
8912    }
8913
8914    #[test]
8915    fn injects_are_no_op_when_unset() {
8916        let protocol = build_head_body_protocol();
8917        let state = test_json!({});
8918        let mut writer = TestWriter::new();
8919        handle(
8920            &protocol,
8921            &state,
8922            &RenderOptions::new("index.html", "/"),
8923            &mut writer,
8924        )
8925        .unwrap();
8926        let html = writer.get_content();
8927        assert!(!html.contains("<link rel=preload>"));
8928        assert!(!html.contains("<script>lr</script>"));
8929    }
8930
8931    #[test]
8932    fn empty_inject_string_treated_as_unset() {
8933        let protocol = build_head_body_protocol();
8934        let state = test_json!({});
8935        let mut writer = TestWriter::new();
8936        let opts = RenderOptions::new("index.html", "/")
8937            .with_head_inject("")
8938            .with_body_inject("");
8939        handle(&protocol, &state, &opts, &mut writer).unwrap();
8940        // No injection happens — empty strings are normalised to None
8941        // by the builder, so the output is identical to the no-options case.
8942        let html = writer.get_content();
8943        assert!(html.contains("</head>"));
8944        assert!(html.contains("</body>"));
8945    }
8946
8947    #[test]
8948    fn inject_html_is_passed_through_verbatim() {
8949        // The handler does NOT escape the inject string — hosts pass
8950        // raw HTML they trust. This test pins that contract: a `<` in
8951        // the inject is emitted as-is, not encoded as `&lt;`.
8952        let protocol = build_head_body_protocol();
8953        let state = test_json!({});
8954        let mut writer = TestWriter::new();
8955        let opts =
8956            RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
8957        handle(&protocol, &state, &opts, &mut writer).unwrap();
8958        assert!(writer.get_content().contains("<script>var x=1;</script>"));
8959    }
8960
8961    /// Both injects fire and appear at the correct structural
8962    /// positions. Critically, this is robust against `</head>` /
8963    /// `</body>` literals appearing elsewhere in the document — the
8964    /// signal-based emitter cannot mis-fire on byte patterns inside
8965    /// HTML comments, `<iframe srcdoc>`, or inline scripts (which the
8966    /// previous byte-scanner could).
8967    #[test]
8968    fn injects_robust_against_marker_literals_in_content() {
8969        let mut fragments = HashMap::new();
8970        // The body intentionally contains `</body>` and `</head>`
8971        // literals before the actual structural close — these came
8972        // from a (hypothetical) iframe srcdoc or comment.
8973        fragments.insert(
8974            "index.html".to_string(),
8975            FragmentList {
8976                fragments: vec![
8977                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
8978                    structural_fragment("head_end"),
8979                    WebUIFragment::raw(
8980                        "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
8981                    ),
8982                    structural_fragment("body_end"),
8983                    WebUIFragment::raw("</body></html>".to_string()),
8984                ],
8985            },
8986        );
8987        let protocol = WebUIProtocol::new(fragments);
8988        let state = test_json!({});
8989        let mut writer = TestWriter::new();
8990        let opts = RenderOptions::new("index.html", "/")
8991            .with_head_inject("<HEAD-INJ>")
8992            .with_body_inject("<BODY-INJ>");
8993        handle(&protocol, &state, &opts, &mut writer).unwrap();
8994        let html = writer.get_content();
8995        // The head inject sits between `<title>x</title>` and the
8996        // first `</head>` — the structural one, not the comment one.
8997        let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
8998        let head_close_idx = html.find("</head>").expect("</head> missing");
8999        assert!(head_inj_idx < head_close_idx);
9000        // The body inject sits before the structural `</body>` — NOT
9001        // before the `</body>` literal in the comment (which would
9002        // require the inject to appear inside `<p>hi</p>` somewhere).
9003        let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
9004        // Find the LAST `</body>` (the structural one).
9005        let body_close_idx = html.rfind("</body>").expect("</body> missing");
9006        assert!(
9007            body_inj_idx < body_close_idx,
9008            "body_inject must precede the structural </body>: {html}"
9009        );
9010        // And the comment is preserved verbatim.
9011        assert!(html.contains("<!-- </body> </head> -->"));
9012    }
9013
9014    /// Coverage-14: both `head_inject` AND `body_inject` set in the
9015    /// same render. Each fires at the correct structural boundary and
9016    /// neither leaks into the other's region.
9017    #[test]
9018    fn both_injects_fire_at_correct_boundaries() {
9019        let protocol = build_head_body_protocol();
9020        let state = test_json!({});
9021        let mut writer = TestWriter::new();
9022        let opts = RenderOptions::new("index.html", "/")
9023            .with_head_inject("<META-HEAD>")
9024            .with_body_inject("<SCRIPT-BODY>");
9025        handle(&protocol, &state, &opts, &mut writer).unwrap();
9026        let html = writer.get_content();
9027        let head_idx = html.find("<META-HEAD>").expect("head inject missing");
9028        let head_close = html.find("</head>").expect("</head> missing");
9029        let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
9030        let body_close = html.find("</body>").expect("</body> missing");
9031        assert!(head_idx < head_close, "head_inject before </head>");
9032        assert!(head_close < body_idx, "body_inject after </head>");
9033        assert!(body_idx < body_close, "body_inject before </body>");
9034        assert_eq!(html.matches("<META-HEAD>").count(), 1);
9035        assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
9036    }
9037
9038    /// Coverage-15 / Bug-3 (security defense): a malformed protocol
9039    /// emitting `head_end` and `body_end` more than once must NOT
9040    /// duplicate the host inject HTML. Without the dedup guard,
9041    /// double-emission would amplify Security-2 (a 1 MiB inject ×
9042    /// 1000 duplicate signals = 1 GiB output).
9043    #[test]
9044    fn injects_dedupe_against_duplicate_signals() {
9045        let mut fragments = HashMap::new();
9046        fragments.insert(
9047            "index.html".to_string(),
9048            FragmentList {
9049                fragments: vec![
9050                    WebUIFragment::raw("<html><head>".to_string()),
9051                    structural_fragment("head_end"),
9052                    structural_fragment("head_end"), // duplicate
9053                    structural_fragment("head_end"), // triplicate
9054                    WebUIFragment::raw("</head><body>".to_string()),
9055                    structural_fragment("body_end"),
9056                    structural_fragment("body_end"), // duplicate
9057                    WebUIFragment::raw("</body></html>".to_string()),
9058                ],
9059            },
9060        );
9061        let protocol = WebUIProtocol::new(fragments);
9062        let state = test_json!({});
9063        let mut writer = TestWriter::new();
9064        let opts = RenderOptions::new("index.html", "/")
9065            .with_head_inject("<HINJ>")
9066            .with_body_inject("<BINJ>");
9067        handle(&protocol, &state, &opts, &mut writer).unwrap();
9068        let html = writer.get_content();
9069        assert_eq!(
9070            html.matches("<HINJ>").count(),
9071            1,
9072            "head_inject must emit exactly once even with duplicate head_end signals"
9073        );
9074        assert_eq!(
9075            html.matches("<BINJ>").count(),
9076            1,
9077            "body_inject must emit exactly once even with duplicate body_end signals"
9078        );
9079    }
9080
9081    /// Coverage-15: a Shadow-DOM / component-only protocol that has NO
9082    /// `<head>` / `<body>` tags must NOT emit the inject (the signals
9083    /// never fire). Verifies the injects are no-ops, not panics.
9084    #[test]
9085    fn injects_no_op_when_no_head_or_body_signals() {
9086        let mut fragments = HashMap::new();
9087        fragments.insert(
9088            "index.html".to_string(),
9089            FragmentList {
9090                fragments: vec![WebUIFragment::raw(
9091                    "<my-component>hi</my-component>".to_string(),
9092                )],
9093            },
9094        );
9095        let protocol = WebUIProtocol::new(fragments);
9096        let state = test_json!({});
9097        let mut writer = TestWriter::new();
9098        let opts = RenderOptions::new("index.html", "/")
9099            .with_head_inject("<HINJ>")
9100            .with_body_inject("<BINJ>");
9101        handle(&protocol, &state, &opts, &mut writer).unwrap();
9102        let html = writer.get_content();
9103        assert!(!html.contains("<HINJ>"), "head_inject must not appear");
9104        assert!(!html.contains("<BINJ>"), "body_inject must not appear");
9105        assert!(html.contains("<my-component>"));
9106    }
9107
9108    /// Coverage-19: the handler's `&self` is shared across threads.
9109    /// Two concurrent renders with different inject values must NOT
9110    /// cross-contaminate (each thread sees only its own inject).
9111    /// Per-render mutable state lives on the `WebUIProcessContext`,
9112    /// which is stack-allocated per call.
9113    #[test]
9114    fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
9115        let protocol = std::sync::Arc::new(build_head_body_protocol());
9116        let state = std::sync::Arc::new(test_json!({}));
9117        let handler = std::sync::Arc::new(WebUIHandler::new());
9118
9119        const N_THREADS: usize = 16;
9120        let mut handles = Vec::with_capacity(N_THREADS);
9121        for tid in 0..N_THREADS {
9122            let h = std::sync::Arc::clone(&handler);
9123            let p = std::sync::Arc::clone(&protocol);
9124            let s = std::sync::Arc::clone(&state);
9125            handles.push(std::thread::spawn(move || {
9126                let head = format!("<HEAD-T{tid}>");
9127                let body = format!("<BODY-T{tid}>");
9128                let mut writer = TestWriter::new();
9129                let opts = RenderOptions::new("index.html", "/")
9130                    .with_head_inject(&head)
9131                    .with_body_inject(&body);
9132                h.handle(&p, &s, &opts, &mut writer).unwrap();
9133                let html = writer.get_content();
9134                // Must contain my own injects exactly once.
9135                assert_eq!(html.matches(&head).count(), 1);
9136                assert_eq!(html.matches(&body).count(), 1);
9137                // Must NOT contain any other thread's inject.
9138                for other in 0..N_THREADS {
9139                    if other == tid {
9140                        continue;
9141                    }
9142                    let other_head = format!("<HEAD-T{other}>");
9143                    let other_body = format!("<BODY-T{other}>");
9144                    assert!(
9145                        !html.contains(&other_head),
9146                        "tid {tid} saw {other}'s head_inject"
9147                    );
9148                    assert!(
9149                        !html.contains(&other_body),
9150                        "tid {tid} saw {other}'s body_inject"
9151                    );
9152                }
9153            }));
9154        }
9155        for h in handles {
9156            h.join().expect("worker panicked");
9157        }
9158    }
9159
9160    /// Coverage-17: a large (1 MiB) head_inject must round-trip
9161    /// correctly without panic, truncation, or excessive overhead.
9162    /// (No size cap is enforced by the handler — the host owns the
9163    /// safety contract; see `with_head_inject` doc comment.)
9164    #[test]
9165    fn large_inject_roundtrips_without_truncation() {
9166        let protocol = build_head_body_protocol();
9167        let state = test_json!({});
9168        let mut writer = TestWriter::new();
9169        let big = "x".repeat(1024 * 1024);
9170        let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
9171        handle(&protocol, &state, &opts, &mut writer).unwrap();
9172        let html = writer.get_content();
9173        assert!(
9174            html.contains(&big),
9175            "large head_inject must be present verbatim ({} bytes)",
9176            big.len()
9177        );
9178        // Sanity: only one copy.
9179        assert_eq!(html.matches(&big).count(), 1);
9180    }
9181
9182    /// `with_nonce("")` must normalize to `None` (no `<meta>` emitted),
9183    /// matching the empty-string semantics of `with_head_inject` /
9184    /// `with_body_inject`. An empty content attribute is browser-
9185    /// ignored noise.
9186    #[test]
9187    fn empty_nonce_treated_as_unset() {
9188        let protocol = build_head_body_protocol();
9189        let state = test_json!({});
9190        let mut writer = TestWriter::new();
9191        let opts = RenderOptions::new("index.html", "/").with_nonce("");
9192        handle(&protocol, &state, &opts, &mut writer).unwrap();
9193        assert!(
9194            !writer.get_content().contains("webui-nonce"),
9195            "empty nonce must not emit <meta name=\"webui-nonce\">"
9196        );
9197    }
9198
9199    /// Regression for the bug Akrosh caught: the `pub` fields on
9200    /// `RenderOptions` let a caller bypass the `with_*` builder
9201    /// normalisation, e.g.:
9202    ///
9203    /// ```ignore
9204    /// RenderOptions { nonce: Some(""), ..RenderOptions::new(e, p) }
9205    /// ```
9206    ///
9207    /// Without defensive normalisation at handler init, this would
9208    /// emit `<script nonce="">` on every inline script. Under a
9209    /// strict `Content-Security-Policy: script-src 'nonce-...'` an
9210    /// empty nonce is a HARD CSP failure that blocks every inline
9211    /// script — a complete inline-script-execution outage.
9212    ///
9213    /// The handler now treats `Some("")` identically to `None` for
9214    /// all three injection points (nonce / head_inject / body_inject)
9215    /// regardless of how the option was populated.
9216    #[test]
9217    fn empty_field_bypass_is_normalised_at_handler_init() {
9218        let protocol = build_head_body_protocol();
9219        let state = test_json!({});
9220
9221        // Bypass the `with_nonce` builder by writing the field directly.
9222        let opts_with_empty_nonce = RenderOptions {
9223            nonce: Some(""),
9224            ..RenderOptions::new("index.html", "/")
9225        };
9226        let mut writer = TestWriter::new();
9227        handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
9228        let html = writer.get_content();
9229        assert!(
9230            !html.contains("webui-nonce"),
9231            "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
9232        );
9233        assert!(
9234            !html.contains("nonce=\"\""),
9235            "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
9236        );
9237
9238        // Same defence for inject fields.
9239        let opts_with_empty_injects = RenderOptions {
9240            head_inject: Some(""),
9241            body_inject: Some(""),
9242            ..RenderOptions::new("index.html", "/")
9243        };
9244        let mut writer = TestWriter::new();
9245        handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
9246        // No assertion needed beyond "doesn't panic and doesn't emit
9247        // empty inject markers" — the head_end / body_end paths must
9248        // treat the empty inject as no-op the same way the builder does.
9249    }
9250
9251    /// Regression for the deep-audit's Bug-6 claim. The for-loop hot-
9252    /// path optimisation (insert key once + `get_mut`-swap value
9253    /// in-place) was suspected of corrupting the outer scope when a
9254    /// nested `<for>` loop reuses the same variable name. This test
9255    /// proves the optimisation is correct under that condition by
9256    /// requiring the outer `item` to be visible before, between, and
9257    /// after the inner loop, with its value preserved across inner
9258    /// iterations.
9259    ///
9260    /// Trace through the optimisation on `outer = [A, B]`,
9261    /// `inner = [X, Y]` with both loops using `item` as the variable:
9262    ///
9263    ///   outer pre-insert "item": Null
9264    ///   iter 1: get_mut → write A
9265    ///     emit "outer:A"
9266    ///     enter inner: saved = remove("item") = Some(A)
9267    ///                  pre-insert "item": Null
9268    ///                  iter 1: write X → emit "inner:X"
9269    ///                  iter 2: write Y → emit "inner:Y"
9270    ///                  restore: insert("item", A)   ← outer's A back
9271    ///     emit "outer:A again"               ← reads A correctly
9272    ///   iter 2: get_mut → write B (overwrites the restored A,
9273    ///                              but that's correct — we're now
9274    ///                              in iter 2 of the outer loop)
9275    ///     emit "outer:B"
9276    ///     enter inner: saved = remove("item") = Some(B), …, restore B
9277    ///     emit "outer:B again"
9278    ///
9279    /// If the audit's claim were correct — that the outer's `get_mut`
9280    /// somehow held a reference past the inner loop and clobbered the
9281    /// restoration — we'd see corrupted values in the "outer:X again"
9282    /// emissions. The assertion below pins the correct sequence.
9283    #[test]
9284    fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
9285        let mut fragments = HashMap::new();
9286        fragments.insert(
9287            "index.html".to_string(),
9288            FragmentList {
9289                fragments: vec![
9290                    WebUIFragment::raw("["),
9291                    WebUIFragment::for_loop("item", "outer", "outer_body"),
9292                    WebUIFragment::raw("]"),
9293                ],
9294            },
9295        );
9296        fragments.insert(
9297            "outer_body".to_string(),
9298            FragmentList {
9299                fragments: vec![
9300                    WebUIFragment::raw("(O="),
9301                    WebUIFragment::signal("item.tag", false),
9302                    WebUIFragment::for_loop("item", "inner", "inner_body"),
9303                    WebUIFragment::raw(",O="),
9304                    WebUIFragment::signal("item.tag", false),
9305                    WebUIFragment::raw(")"),
9306                ],
9307            },
9308        );
9309        fragments.insert(
9310            "inner_body".to_string(),
9311            FragmentList {
9312                fragments: vec![
9313                    WebUIFragment::raw("[I="),
9314                    WebUIFragment::signal("item.tag", false),
9315                    WebUIFragment::raw("]"),
9316                ],
9317            },
9318        );
9319        let protocol = WebUIProtocol::new(fragments);
9320        let state = test_json!({
9321            "outer": [{"tag": "A"}, {"tag": "B"}],
9322            "inner": [{"tag": "X"}, {"tag": "Y"}],
9323        });
9324        let mut writer = TestWriter::new();
9325        handle(
9326            &protocol,
9327            &state,
9328            &RenderOptions::new("index.html", "/"),
9329            &mut writer,
9330        )
9331        .unwrap();
9332        // Expected sequence:
9333        //   outer iter 1 (item=A):
9334        //     emit "(O=A"               ← outer A before inner
9335        //     inner iter 1 (item=X) emit "[I=X]"
9336        //     inner iter 2 (item=Y) emit "[I=Y]"
9337        //     emit ",O=A)"              ← outer A AFTER inner restore
9338        //   outer iter 2 (item=B):
9339        //     emit "(O=B"
9340        //     inner iter 1 (item=X) emit "[I=X]"
9341        //     inner iter 2 (item=Y) emit "[I=Y]"
9342        //     emit ",O=B)"
9343        assert_eq!(
9344            writer.get_content(),
9345            "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
9346            "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
9347        );
9348    }
9349
9350    #[derive(Default)]
9351    struct FlushTestWriter {
9352        output: String,
9353        flushes: Vec<usize>,
9354        fail_flush: bool,
9355        fail_flush_at: Option<usize>,
9356        flush_attempts: usize,
9357        ended: bool,
9358    }
9359
9360    impl ResponseWriter for FlushTestWriter {
9361        fn write(&mut self, content: &str) -> Result<()> {
9362            self.output.push_str(content);
9363            Ok(())
9364        }
9365
9366        fn end(&mut self) -> Result<()> {
9367            self.ended = true;
9368            Ok(())
9369        }
9370    }
9371
9372    impl FlushWriter for FlushTestWriter {
9373        fn flush(&mut self) -> Result<()> {
9374            let attempt = self.flush_attempts;
9375            self.flush_attempts += 1;
9376            if self.fail_flush || self.fail_flush_at == Some(attempt) {
9377                return Err(HandlerError::ClientDisconnected);
9378            }
9379            self.flushes.push(self.output.len());
9380            Ok(())
9381        }
9382    }
9383
9384    fn streaming_protocol(with_boundaries: bool) -> Protocol {
9385        streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
9386    }
9387
9388    fn streaming_protocol_with_state_strategy(
9389        with_boundaries: bool,
9390        state_strategy: InitialStateStrategy,
9391    ) -> Protocol {
9392        let mut fragments = HashMap::new();
9393        let mut entry = vec![
9394            WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
9395            structural_fragment("head_start"),
9396            WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
9397            structural_fragment("head_end"),
9398            WebUIFragment::raw("</HEAD><body>"),
9399            structural_fragment("body_start"),
9400        ];
9401        if with_boundaries {
9402            entry.push(structural_fragment("boundary_start:0"));
9403        }
9404        entry.extend([
9405            WebUIFragment::raw("<my-counter"),
9406            structural_fragment("streaming_root:my-counter"),
9407            WebUIFragment::raw(">"),
9408            WebUIFragment::component("my-counter"),
9409            WebUIFragment::raw("</my-counter>"),
9410        ]);
9411        if with_boundaries {
9412            entry.push(structural_fragment("boundary_end:0"));
9413        }
9414        entry.extend([
9415            WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
9416            structural_fragment("body_end"),
9417            WebUIFragment::raw("</body></html>"),
9418        ]);
9419        fragments.insert("index.html".to_string(), FragmentList { fragments: entry });
9420        fragments.insert(
9421            "my-counter".to_string(),
9422            FragmentList {
9423                fragments: vec![WebUIFragment::raw("<button>Count</button>")],
9424            },
9425        );
9426
9427        let mut document = WebUIProtocol::new(fragments);
9428        document.initial_state_strategy = state_strategy as i32;
9429        document.components.insert(
9430            "my-counter".to_string(),
9431            webui_protocol::ComponentData {
9432                template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
9433                template_functions: "[function(){return true}]".to_string(),
9434                hydration_mode: StateProjectionMode::Keys as i32,
9435                hydration_keys: vec!["count".to_string()],
9436                ..Default::default()
9437            },
9438        );
9439        Protocol::new(document)
9440    }
9441
9442    #[test]
9443    fn handler_error_stays_small() {
9444        // Boxing the cold `StreamingBoundary` payload keeps `HandlerError` — and
9445        // therefore `Result<(), HandlerError>` threaded through the hot legacy
9446        // render path — down to a single `String`-sized payload plus a
9447        // discriminant word. If the boundary payload is un-boxed back to
9448        // `{ signal, reason }` it grows to two `String`s (48-byte payload) and
9449        // this fails.
9450        assert!(
9451            std::mem::size_of::<HandlerError>()
9452                <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
9453            "HandlerError grew to {} bytes",
9454            std::mem::size_of::<HandlerError>()
9455        );
9456    }
9457
9458    #[test]
9459    fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
9460        let protocol = streaming_protocol(true);
9461        let handler = WebUIHandler::with_plugin(|| {
9462            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9463        });
9464        let state = test_json!({ "count": 1, "serverOnly": "secret" });
9465        let mut writer = FlushTestWriter::default();
9466
9467        handler
9468            .render_streaming(
9469                &protocol,
9470                &state,
9471                &RenderOptions::new("index.html", "/"),
9472                &mut writer,
9473            )
9474            .unwrap();
9475
9476        assert!(writer.ended);
9477        assert_eq!(
9478            writer.flushes.len(),
9479            2,
9480            "boundary commit plus one coalesced terminal-tail flush"
9481        );
9482        let first_flush = &writer.output[..writer.flushes[0]];
9483        assert!(first_flush.contains("<!--wb:0-->"));
9484        assert!(first_flush.contains("<!--/wb:0-->"));
9485        assert!(first_flush.contains(r#"[1,0,0,0,{"inventory":"01","state":{"count":1}"#));
9486        assert!(first_flush.contains(r#""templates":{"my-counter":"#));
9487        assert!(!first_flush.contains("slow tail"));
9488        assert!(!writer.output.contains("id=\"webui-data\""));
9489        // The terminal flush commits the scriptless tail without manufacturing
9490        // another state/template projection.
9491        assert!(writer.output.contains("[1,1,3,0,{}]"));
9492        assert!(!writer.output.contains("[1,1,0,1,"));
9493        assert!(!writer.output.contains("[1,2,3,0,{}]"));
9494
9495        let marker = writer
9496            .output
9497            .find(STREAMING_MARKER)
9498            .expect("streaming marker");
9499        let attributed_head = writer
9500            .output
9501            .find("<HEAD data-shell=\"main\">")
9502            .expect("attributed mixed-case head");
9503        let authored_script = writer
9504            .output
9505            .find("src=\"/index.js\"")
9506            .expect("entry script");
9507        assert!(attributed_head < marker && marker < authored_script);
9508    }
9509
9510    #[test]
9511    fn streaming_terminal_tail_never_resends_full_state() {
9512        let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
9513        let handler = WebUIHandler::with_plugin(|| {
9514            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9515        });
9516        let mut writer = FlushTestWriter::default();
9517
9518        handler
9519            .render_streaming(
9520                &protocol,
9521                &test_json!({ "count": 1, "serverOnly": "secret" }),
9522                &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
9523                &mut writer,
9524            )
9525            .unwrap();
9526
9527        assert_eq!(
9528            writer.output.matches("serverOnly").count(),
9529            1,
9530            "full state belongs only to the interactive boundary"
9531        );
9532        assert!(writer.output.contains("[1,1,3,0,{}]"));
9533    }
9534
9535    /// Streaming must place the reserved-state injects exactly where the
9536    /// ordinary render does, so a host can switch modes without its
9537    /// boundary HTML moving.
9538    #[test]
9539    fn state_inject_placement_matches_between_render_modes() {
9540        let entry = vec![
9541            WebUIFragment::raw("<html><head>"),
9542            structural_fragment("head_start"),
9543            structural_fragment("head_end"),
9544            WebUIFragment::raw("</head><body>"),
9545            structural_fragment("body_start"),
9546            WebUIFragment::raw("<main>static</main>"),
9547            structural_fragment("body_end"),
9548            WebUIFragment::raw("</body></html>"),
9549        ];
9550        let fragments =
9551            HashMap::from([("index.html".to_string(), FragmentList { fragments: entry })]);
9552        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9553        let state = test_json!({
9554            "$webui": {
9555                "headEnd": "<meta name=he>",
9556                "bodyStart": "<span id=bs></span>",
9557                "bodyEnd": "<script>be</script>",
9558            }
9559        });
9560        let options = RenderOptions::new("index.html", "/");
9561
9562        let mut ordinary = TestWriter::new();
9563        WebUIHandler::new()
9564            .render(&protocol, &state, &options, &mut ordinary)
9565            .unwrap();
9566        let ordinary_html = ordinary.get_content().to_string();
9567
9568        let mut streamed = FlushTestWriter::default();
9569        WebUIHandler::new()
9570            .render_streaming(&protocol, &state, &options, &mut streamed)
9571            .unwrap();
9572        let streamed_html = &streamed.output;
9573
9574        for html in [ordinary_html.as_str(), streamed_html.as_str()] {
9575            let head_end = html.find("<meta name=he>").expect("headEnd missing");
9576            let head_close = html.find("</head>").expect("</head> missing");
9577            let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
9578            let main = html.find("<main>static</main>").expect("content missing");
9579            let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
9580            let body_close = html.find("</body>").expect("</body> missing");
9581            assert!(head_end < head_close, "headEnd misplaced: {html}");
9582            assert!(body_start < main, "bodyStart misplaced: {html}");
9583            assert!(
9584                main < body_end && body_end < body_close,
9585                "bodyEnd misplaced: {html}"
9586            );
9587        }
9588
9589        // The streaming response still terminates with its single empty
9590        // terminal record: an inject must not perturb the record stream.
9591        assert!(
9592            streamed_html.contains(",3,0,{}]"),
9593            "streaming must still end in one empty terminal record: {streamed_html}"
9594        );
9595    }
9596
9597    #[test]
9598    fn streaming_state_inject_emits_and_strips_reserved_key() {
9599        let entry = vec![
9600            WebUIFragment::raw("<html><head>"),
9601            structural_fragment("head_start"),
9602            structural_fragment("head_end"),
9603            WebUIFragment::raw("</head><body>"),
9604            structural_fragment("body_start"),
9605            structural_fragment("body_end"),
9606            WebUIFragment::raw("</body></html>"),
9607        ];
9608        let fragments =
9609            HashMap::from([("index.html".to_string(), FragmentList { fragments: entry })]);
9610        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9611        let state = test_json!({ "$webui": { "bodyEnd": "<script>be</script>" } });
9612
9613        let mut writer = FlushTestWriter::default();
9614        WebUIHandler::new()
9615            .render_streaming(
9616                &protocol,
9617                &state,
9618                &RenderOptions::new("index.html", "/"),
9619                &mut writer,
9620            )
9621            .unwrap();
9622        assert!(writer.output.contains("<script>be</script>"));
9623        assert!(!writer.output.contains("$webui"));
9624    }
9625
9626    #[test]
9627    fn static_streaming_document_uses_one_empty_terminal_record() {
9628        let fragments = HashMap::from([(
9629            "index.html".to_string(),
9630            FragmentList {
9631                fragments: vec![
9632                    WebUIFragment::raw("<html><head>"),
9633                    structural_fragment("head_start"),
9634                    structural_fragment("head_end"),
9635                    WebUIFragment::raw("</head><body>"),
9636                    structural_fragment("body_start"),
9637                    WebUIFragment::raw("<main>static</main>"),
9638                    structural_fragment("body_end"),
9639                    WebUIFragment::raw("</body></html>"),
9640                ],
9641            },
9642        )]);
9643        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9644        let mut writer = FlushTestWriter::default();
9645
9646        WebUIHandler::new()
9647            .render_streaming(
9648                &protocol,
9649                &test_json!({ "serverOnly": "secret" }),
9650                &RenderOptions::new("index.html", "/"),
9651                &mut writer,
9652            )
9653            .unwrap();
9654
9655        assert_eq!(writer.flushes.len(), 1);
9656        assert!(writer.output.contains(STREAMING_MARKER));
9657        assert!(writer.output.contains("[1,0,3,0,{}]"));
9658        assert!(!writer.output.contains("serverOnly"));
9659        assert!(!writer.output.contains("id=\"webui-data\""));
9660        assert!(!writer.output.contains("<!--wb:"));
9661    }
9662
9663    #[test]
9664    fn streaming_render_emits_nonce_and_functions_before_sentinel() {
9665        let protocol = streaming_protocol(true);
9666        let handler = WebUIHandler::with_plugin(|| {
9667            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9668        });
9669        let mut writer = FlushTestWriter::default();
9670        handler
9671            .render_streaming(
9672                &protocol,
9673                &test_json!({ "count": 1 }),
9674                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
9675                &mut writer,
9676            )
9677            .unwrap();
9678
9679        let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
9680        let envelope = writer.output[boundary_end..]
9681            .find("data-webui-boundary nonce=\"test-nonce-123\"")
9682            .map(|index| index + boundary_end)
9683            .expect("nonce-bearing envelope");
9684        let functions = writer
9685            .output
9686            .find("templateFns")
9687            .expect("function side channel");
9688        let sentinel = writer
9689            .output
9690            .find("<webui-hydrate>")
9691            .expect("hydration sentinel");
9692        assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
9693        assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
9694    }
9695
9696    #[test]
9697    fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
9698        fn protocol(signals: &[&str]) -> Protocol {
9699            let mut protocol_signals = Vec::with_capacity(signals.len() + 1);
9700            protocol_signals.push(structural_fragment("head_start"));
9701            protocol_signals.extend(signals.iter().map(|signal| structural_fragment(*signal)));
9702            let fragments = HashMap::from([(
9703                "index.html".to_string(),
9704                FragmentList {
9705                    fragments: protocol_signals,
9706                },
9707            )]);
9708            Protocol::new(WebUIProtocol::new(fragments))
9709        }
9710
9711        let handler = WebUIHandler::new();
9712        let mut writer = FlushTestWriter::default();
9713        let result = handler.render_streaming(
9714            &protocol(&["boundary_start:1"]),
9715            &test_json!({}),
9716            &RenderOptions::new("index.html", "/"),
9717            &mut writer,
9718        );
9719        match result {
9720            Err(HandlerError::StreamingBoundary(err)) => {
9721                assert_eq!(err.signal, "boundary_start:1");
9722                assert!(err.reason.contains("expected boundary sequence 0"));
9723            }
9724            other => panic!("expected ordered-boundary error, got {other:?}"),
9725        }
9726
9727        let mut writer = FlushTestWriter::default();
9728        let result = handler.render_streaming(
9729            &protocol(&["boundary_start:0", "boundary_start:1"]),
9730            &test_json!({}),
9731            &RenderOptions::new("index.html", "/"),
9732            &mut writer,
9733        );
9734        assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
9735    }
9736
9737    #[test]
9738    fn streaming_render_propagates_boundary_flush_disconnect() {
9739        let protocol = streaming_protocol(true);
9740        let handler = WebUIHandler::new();
9741        let mut writer = FlushTestWriter {
9742            fail_flush: true,
9743            ..FlushTestWriter::default()
9744        };
9745        let result = handler.render_streaming(
9746            &protocol,
9747            &test_json!({}),
9748            &RenderOptions::new("index.html", "/"),
9749            &mut writer,
9750        );
9751        assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
9752        assert!(!writer.ended);
9753    }
9754
9755    #[test]
9756    fn streaming_render_requires_structural_head_start_before_writing() {
9757        let fragments = HashMap::from([(
9758            "index.html".to_string(),
9759            FragmentList {
9760                fragments: vec![
9761                    WebUIFragment::raw(
9762                        "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
9763                    ),
9764                    structural_fragment("head_end"),
9765                    WebUIFragment::raw("</HEAD><body>"),
9766                    structural_fragment("body_start"),
9767                    structural_fragment("body_end"),
9768                ],
9769            },
9770        )]);
9771        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9772        let mut writer = FlushTestWriter::default();
9773
9774        let result = WebUIHandler::new().render_streaming(
9775            &protocol,
9776            &test_json!({}),
9777            &RenderOptions::new("index.html", "/"),
9778            &mut writer,
9779        );
9780
9781        assert!(matches!(
9782            result,
9783            Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
9784        ));
9785        assert!(
9786            writer.output.is_empty(),
9787            "preflight must fail before output"
9788        );
9789        assert!(writer.flushes.is_empty());
9790        assert!(!writer.ended);
9791    }
9792
9793    #[test]
9794    fn streaming_render_rejects_duplicate_head_start() {
9795        let fragments = HashMap::from([(
9796            "index.html".to_string(),
9797            FragmentList {
9798                fragments: vec![
9799                    WebUIFragment::raw("<html><head>"),
9800                    structural_fragment("head_start"),
9801                    structural_fragment("head_start"),
9802                    structural_fragment("head_end"),
9803                    WebUIFragment::raw("</head><body>"),
9804                    structural_fragment("body_start"),
9805                    structural_fragment("body_end"),
9806                ],
9807            },
9808        )]);
9809        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9810        let mut writer = FlushTestWriter::default();
9811
9812        let result = WebUIHandler::new().render_streaming(
9813            &protocol,
9814            &test_json!({}),
9815            &RenderOptions::new("index.html", "/"),
9816            &mut writer,
9817        );
9818
9819        assert!(matches!(
9820            result,
9821            Err(HandlerError::DuplicateStreamingHeadStart)
9822        ));
9823        assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
9824        assert!(writer.flushes.is_empty());
9825    }
9826
9827    #[test]
9828    fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
9829        let handler = WebUIHandler::with_plugin(|| {
9830            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9831        });
9832        let state = test_json!({
9833            "count": 1,
9834            "head_start": "must not render",
9835            "boundary_start:0": "must not render",
9836            "boundary_end:0": "must not render",
9837        });
9838        let mut with_boundaries = TestWriter::new();
9839        let mut without_boundaries = TestWriter::new();
9840        handler
9841            .render(
9842                &streaming_protocol(true),
9843                &state,
9844                &RenderOptions::new("index.html", "/"),
9845                &mut with_boundaries,
9846            )
9847            .unwrap();
9848        handler
9849            .render(
9850                &streaming_protocol(false),
9851                &state,
9852                &RenderOptions::new("index.html", "/"),
9853                &mut without_boundaries,
9854            )
9855            .unwrap();
9856        assert_eq!(
9857            with_boundaries.get_content(),
9858            without_boundaries.get_content()
9859        );
9860    }
9861
9862    #[test]
9863    fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
9864        for source in [
9865            r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
9866            r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
9867        ] {
9868            let mut parser = HtmlParser::new();
9869            parser
9870                .parse("index.html", source)
9871                .expect("parse head fixture");
9872            let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
9873            let mut writer = TestWriter::new();
9874            WebUIHandler::new()
9875                .render(
9876                    &protocol,
9877                    &test_json!({ "theme": "light" }),
9878                    &RenderOptions::new("index.html", "/"),
9879                    &mut writer,
9880                )
9881                .unwrap();
9882
9883            assert_eq!(
9884                writer.get_content(),
9885                "<html><head><title>T</title></head><body>x</body></html>"
9886            );
9887        }
9888    }
9889
9890    #[test]
9891    fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
9892        let source =
9893            r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
9894        let mut parser = HtmlParser::new();
9895        parser.parse("index.html", source).expect("parse fixture");
9896        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
9897
9898        let mut ordinary = TestWriter::new();
9899        WebUIHandler::new()
9900            .render(
9901                &protocol,
9902                &test_json!({}),
9903                &RenderOptions::new("index.html", "/"),
9904                &mut ordinary,
9905            )
9906            .unwrap();
9907        assert_eq!(ordinary.get_content(), source);
9908
9909        let mut streaming = FlushTestWriter::default();
9910        WebUIHandler::new()
9911            .render_streaming(
9912                &protocol,
9913                &test_json!({}),
9914                &RenderOptions::new("index.html", "/"),
9915                &mut streaming,
9916            )
9917            .unwrap();
9918        assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
9919        assert!(streaming.output.contains("</HEAD><BODY>x"));
9920        let opening = streaming.output.find("<HEAD").expect("mixed-case head");
9921        let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
9922        let title = streaming.output.find("<title>").expect("title");
9923        assert!(opening < marker && marker < title);
9924        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
9925    }
9926
9927    /// Only `}}}webui:`-namespaced signals are compiler-owned, so an
9928    /// unprefixed `body_end` is ordinary authored content.
9929    #[test]
9930    fn unnamespaced_signal_is_ordinary_content() {
9931        let fragments = HashMap::from([(
9932            "index.html".to_string(),
9933            FragmentList {
9934                fragments: vec![
9935                    WebUIFragment::raw("<html><head>"),
9936                    structural_fragment("head_end"),
9937                    WebUIFragment::raw("</head><body>"),
9938                    WebUIFragment::signal("body_end".to_string(), false),
9939                    structural_fragment("body_end"),
9940                    WebUIFragment::raw("</body></html>"),
9941                ],
9942            },
9943        )]);
9944        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9945        let mut writer = TestWriter::new();
9946        WebUIHandler::new()
9947            .render(
9948                &protocol,
9949                &test_json!({ "body_end": "content" }),
9950                &RenderOptions::new("index.html", "/"),
9951                &mut writer,
9952            )
9953            .expect("current protocol must render");
9954        assert!(writer.get_content().contains("content"));
9955    }
9956
9957    #[test]
9958    fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
9959        let source = concat!(
9960            "<html><head></head><body>",
9961            "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
9962            "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
9963            "</body></html>",
9964        );
9965        let state = test_json!({
9966            "head_start": "hs",
9967            "head_end": "he",
9968            "body_start": "bs",
9969            "body_end": "be",
9970            "boundary_start:0": "b0s",
9971            "boundary_end:0": "b0e",
9972            "streaming_root:forged": "root",
9973        });
9974        let mut parser = HtmlParser::new();
9975        parser.parse("index.html", source).expect("parse fixture");
9976        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
9977        let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
9978
9979        let mut ordinary = TestWriter::new();
9980        WebUIHandler::new()
9981            .render(
9982                &protocol,
9983                &state,
9984                &RenderOptions::new("index.html", "/"),
9985                &mut ordinary,
9986            )
9987            .unwrap();
9988        assert_eq!(ordinary.get_content(), expected);
9989
9990        let mut streaming = FlushTestWriter::default();
9991        WebUIHandler::new()
9992            .render_streaming(
9993                &protocol,
9994                &state,
9995                &RenderOptions::new("index.html", "/"),
9996                &mut streaming,
9997            )
9998            .unwrap();
9999        assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
10000        assert!(!streaming.output.contains("<!--wb:"));
10001        assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
10002        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
10003    }
10004
10005    #[test]
10006    fn streaming_rejects_structural_signals_after_terminal() {
10007        let fragments = HashMap::from([(
10008            "index.html".to_string(),
10009            FragmentList {
10010                fragments: vec![
10011                    WebUIFragment::raw("<html><head>"),
10012                    structural_fragment("head_start"),
10013                    structural_fragment("head_end"),
10014                    WebUIFragment::raw("</head><body>"),
10015                    structural_fragment("body_start"),
10016                    structural_fragment("body_end"),
10017                    WebUIFragment::raw("</body>"),
10018                    structural_fragment("boundary_start:0"),
10019                    WebUIFragment::raw("</html>"),
10020                ],
10021            },
10022        )]);
10023        let protocol = Protocol::new(WebUIProtocol::new(fragments));
10024        let mut streaming = FlushTestWriter::default();
10025        let result = WebUIHandler::new().render_streaming(
10026            &protocol,
10027            &test_json!({}),
10028            &RenderOptions::new("index.html", "/"),
10029            &mut streaming,
10030        );
10031
10032        match result {
10033            Err(HandlerError::StreamingBoundary(error)) => {
10034                assert_eq!(error.signal, "boundary_start:0");
10035                assert!(error.reason.contains("after the body_end terminal record"));
10036            }
10037            other => panic!("expected post-terminal rejection, got {other:?}"),
10038        }
10039        assert!(streaming.output.is_empty());
10040        assert!(!streaming.output.contains("<!--wb:0-->"));
10041
10042        let mut ordinary = TestWriter::new();
10043        WebUIHandler::new()
10044            .render(
10045                &protocol,
10046                &test_json!({}),
10047                &RenderOptions::new("index.html", "/"),
10048                &mut ordinary,
10049            )
10050            .unwrap();
10051        assert_eq!(
10052            ordinary.get_content(),
10053            "<html><head></head><body></body></html>"
10054        );
10055    }
10056
10057    /// Build a streaming protocol with one boundary per `hosts` entry. Each
10058    /// boundary wraps a component host whose opening tag is split so the
10059    /// compiler-owned `streaming_root:<tag>` signal (optionally emitted) lands
10060    /// inside the tag, exactly as `HtmlParser` produces. Components `comp-a` and
10061    /// `comp-b` carry disjoint templates and disjoint hydration keys.
10062    fn disjoint_streaming_protocol_ext(hosts: &[&str], emit_root_signal: bool) -> Protocol {
10063        let mut fragments = HashMap::new();
10064        let mut entry = vec![
10065            WebUIFragment::raw("<!DOCTYPE html><html><head>"),
10066            structural_fragment("head_start"),
10067            structural_fragment("head_end"),
10068            WebUIFragment::raw("</head><body>"),
10069            structural_fragment("body_start"),
10070        ];
10071        for (sequence, host) in hosts.iter().enumerate() {
10072            entry.push(structural_fragment(format!("boundary_start:{sequence}")));
10073            entry.push(WebUIFragment::raw(format!("<{host}")));
10074            if emit_root_signal {
10075                entry.push(structural_fragment(format!("streaming_root:{host}")));
10076            }
10077            entry.push(WebUIFragment::raw(">"));
10078            entry.push(WebUIFragment::component(*host));
10079            entry.push(WebUIFragment::raw(format!("</{host}>")));
10080            entry.push(structural_fragment(format!("boundary_end:{sequence}")));
10081        }
10082        entry.push(structural_fragment("body_end"));
10083        entry.push(WebUIFragment::raw("</body></html>"));
10084        fragments.insert("index.html".to_string(), FragmentList { fragments: entry });
10085        fragments.insert(
10086            "comp-a".to_string(),
10087            FragmentList {
10088                fragments: vec![WebUIFragment::raw("<b>A</b>")],
10089            },
10090        );
10091        fragments.insert(
10092            "comp-b".to_string(),
10093            FragmentList {
10094                fragments: vec![WebUIFragment::raw("<b>B</b>")],
10095            },
10096        );
10097
10098        let mut document = WebUIProtocol::new(fragments);
10099        document.initial_state_strategy = InitialStateStrategy::Components as i32;
10100        document.streaming_boundaries.insert(
10101            "index.html".to_string(),
10102            webui_protocol::StreamingBoundaryList {
10103                names: (0..hosts.len())
10104                    .map(|index| format!("boundary-{index}"))
10105                    .collect(),
10106            },
10107        );
10108        document.components.insert(
10109            "comp-a".to_string(),
10110            webui_protocol::ComponentData {
10111                template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
10112                hydration_mode: StateProjectionMode::Keys as i32,
10113                hydration_keys: vec!["a_count".to_string()],
10114                ..Default::default()
10115            },
10116        );
10117        document.components.insert(
10118            "comp-b".to_string(),
10119            webui_protocol::ComponentData {
10120                template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
10121                hydration_mode: StateProjectionMode::Keys as i32,
10122                hydration_keys: vec!["b_count".to_string()],
10123                ..Default::default()
10124            },
10125        );
10126        Protocol::new(document)
10127    }
10128
10129    fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
10130        disjoint_streaming_protocol_ext(hosts, true)
10131    }
10132
10133    fn streaming_plan_validation_protocol(signals: &[&str], names: &[&str]) -> Protocol {
10134        let mut entry = vec![
10135            WebUIFragment::raw("<html><head>"),
10136            structural_fragment("head_start"),
10137            structural_fragment("head_end"),
10138            WebUIFragment::raw("</head><body>"),
10139            structural_fragment("body_start"),
10140        ];
10141        entry.extend(signals.iter().map(structural_fragment));
10142        entry.extend([
10143            structural_fragment("body_end"),
10144            WebUIFragment::raw("</body></html>"),
10145        ]);
10146
10147        let mut document = WebUIProtocol::new(HashMap::from([(
10148            "index.html".to_string(),
10149            FragmentList { fragments: entry },
10150        )]));
10151        document.streaming_boundaries.insert(
10152            "index.html".to_string(),
10153            webui_protocol::StreamingBoundaryList {
10154                names: names.iter().map(|name| (*name).to_string()).collect(),
10155            },
10156        );
10157        Protocol::new(document)
10158    }
10159
10160    #[test]
10161    fn streaming_response_rejects_cached_malformed_boundary_plan_before_writing() {
10162        let protocol =
10163            streaming_plan_validation_protocol(&["boundary_start:0", "boundary_end:1"], &["one"]);
10164        let handler = WebUIHandler::new();
10165        let mut writer = FlushTestWriter::default();
10166        let options = RenderOptions::new("index.html", "/");
10167
10168        let error = match handler.stream_response(&protocol, &options, &mut writer) {
10169            Ok(_) => panic!("malformed boundary plan unexpectedly opened a response"),
10170            Err(error) => error,
10171        };
10172
10173        assert!(
10174            matches!(error, HandlerError::StreamingBoundary(_)),
10175            "error: {error}"
10176        );
10177        assert!(writer.output.is_empty());
10178        assert!(writer.flushes.is_empty());
10179    }
10180
10181    #[test]
10182    fn streaming_response_rejects_boundary_name_count_mismatch_before_writing() {
10183        let protocol = streaming_plan_validation_protocol(
10184            &["boundary_start:0", "boundary_end:0"],
10185            &["one", "two"],
10186        );
10187        let handler = WebUIHandler::new();
10188        let mut writer = FlushTestWriter::default();
10189        let options = RenderOptions::new("index.html", "/");
10190
10191        let error = match handler.stream_response(&protocol, &options, &mut writer) {
10192            Ok(_) => panic!("mismatched boundary names unexpectedly opened a response"),
10193            Err(error) => error,
10194        };
10195
10196        assert!(
10197            matches!(error, HandlerError::Invariant(_)),
10198            "error: {error}"
10199        );
10200        assert!(writer.output.is_empty());
10201        assert!(writer.flushes.is_empty());
10202    }
10203
10204    #[test]
10205    fn streaming_response_interleaves_projected_updates_with_later_boundaries() {
10206        let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b"]);
10207        let handler = WebUIHandler::with_plugin(|| {
10208            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10209        });
10210        let mut writer = FlushTestWriter::default();
10211        let options = RenderOptions::new("index.html", "/");
10212        let mut response = handler
10213            .stream_response(&protocol, &options, &mut writer)
10214            .unwrap();
10215        let first = response.boundary("boundary-0").unwrap();
10216        let second = response.boundary("boundary-1").unwrap();
10217
10218        response.write_shell(&test_json!({})).unwrap();
10219        response
10220            .write_boundary(
10221                first,
10222                &test_json!({ "a_count": 1, "serverOnly": "secret" }),
10223                BoundaryMode::Updatable,
10224            )
10225            .unwrap();
10226        response
10227            .update(first, &test_json!({ "a_count": 7, "serverOnly": "secret" }))
10228            .unwrap();
10229        response
10230            .write_boundary(
10231                second,
10232                &test_json!({ "b_count": 2, "serverOnly": "secret" }),
10233                BoundaryMode::Final,
10234            )
10235            .unwrap();
10236        response.finish(&test_json!({})).unwrap();
10237
10238        assert_eq!(writer.flushes.len(), 5);
10239        assert!(writer.output.contains(r#"[1,0,1,0,{"#));
10240        assert!(writer.output.contains(r#""state":{"a_count":1}"#));
10241        assert!(writer.output.contains(r#"[1,1,2,0,{"a_count":7}]"#));
10242        assert!(writer.output.contains(r#"[1,2,0,1,{"#));
10243        assert!(writer.output.contains(r#""state":{"b_count":2}"#));
10244        assert!(writer.output.contains("[1,3,3,0,{}]"));
10245        assert_eq!(writer.output.matches("serverOnly").count(), 0);
10246
10247        let update_start = writer.output.find("[1,1,2,0,").unwrap();
10248        let update_end = writer.output[update_start..]
10249            .find("</script>")
10250            .map(|offset| update_start + offset)
10251            .unwrap();
10252        let update = &writer.output[update_start..update_end];
10253        assert!(!update.contains("inventory"));
10254        assert!(!update.contains("templates"));
10255    }
10256
10257    #[test]
10258    fn owned_streaming_session_matches_borrowed_response_bytes() {
10259        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a", "comp-b"]));
10260        let handler = Arc::new(WebUIHandler::with_plugin(|| {
10261            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10262        }));
10263
10264        let mut writer = FlushTestWriter::default();
10265        let options = RenderOptions::new("index.html", "/");
10266        let mut response = handler
10267            .stream_response(&protocol, &options, &mut writer)
10268            .unwrap();
10269        let first = response.boundary("boundary-0").unwrap();
10270        let second = response.boundary("boundary-1").unwrap();
10271        response.write_shell(&test_json!({})).unwrap();
10272        response
10273            .write_boundary(
10274                first,
10275                &test_json!({ "a_count": 1 }),
10276                BoundaryMode::Updatable,
10277            )
10278            .unwrap();
10279        response
10280            .update(first, &test_json!({ "a_count": 7 }))
10281            .unwrap();
10282        response
10283            .write_boundary(second, &test_json!({ "b_count": 2 }), BoundaryMode::Final)
10284            .unwrap();
10285        response.finish(&test_json!({})).unwrap();
10286
10287        let mut session = StreamingSession::new(
10288            Arc::clone(&handler),
10289            Arc::clone(&protocol),
10290            SessionOptions::new("index.html", "/"),
10291        )
10292        .unwrap();
10293        let session_first = session.boundary("boundary-0").unwrap();
10294        let session_second = session.boundary("boundary-1").unwrap();
10295        assert_eq!(session_first, first);
10296        assert_eq!(session_second, second);
10297        assert_eq!(session.boundary_count(), 2);
10298
10299        let chunks: Vec<Vec<u8>> = vec![
10300            session.write_shell(&test_json!({})).unwrap(),
10301            session
10302                .write_boundary(
10303                    session_first,
10304                    &test_json!({ "a_count": 1 }),
10305                    BoundaryMode::Updatable,
10306                )
10307                .unwrap(),
10308            session
10309                .update(session_first, &test_json!({ "a_count": 7 }))
10310                .unwrap(),
10311            session
10312                .write_boundary(
10313                    session_second,
10314                    &test_json!({ "b_count": 2 }),
10315                    BoundaryMode::Final,
10316                )
10317                .unwrap(),
10318            session.finish(&test_json!({})).unwrap(),
10319        ];
10320
10321        // One chunk per host call, matching the borrowed path's flush count.
10322        assert_eq!(chunks.len(), writer.flushes.len());
10323        assert!(session.is_finished());
10324        let joined = String::from_utf8(chunks.concat()).unwrap();
10325        assert_eq!(joined, writer.output);
10326    }
10327
10328    #[test]
10329    fn owned_streaming_session_rejects_use_after_finish() {
10330        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"]));
10331        let handler = Arc::new(WebUIHandler::new());
10332        let mut session =
10333            StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
10334                .unwrap();
10335        let boundary = session.boundary("boundary-0").unwrap();
10336        session.write_shell(&test_json!({})).unwrap();
10337        session
10338            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
10339            .unwrap();
10340        session.finish(&test_json!({})).unwrap();
10341
10342        let error = session.write_shell(&test_json!({})).unwrap_err();
10343        assert!(error.to_string().contains("already finished"));
10344        assert!(session.finish(&test_json!({})).is_err());
10345    }
10346
10347    #[test]
10348    fn owned_streaming_session_surfaces_unknown_boundary_names() {
10349        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"]));
10350        let handler = Arc::new(WebUIHandler::new());
10351        let session =
10352            StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
10353                .unwrap();
10354        let error = session.boundary("boundary-O").unwrap_err().to_string();
10355        assert!(error.contains("boundary-0"));
10356    }
10357
10358    #[test]
10359    fn owned_streaming_session_stays_usable_after_a_rejected_call() {
10360        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"]));
10361        let handler = Arc::new(WebUIHandler::new());
10362        let mut session =
10363            StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
10364                .unwrap();
10365        let boundary = session.boundary("boundary-0").unwrap();
10366        session.write_shell(&test_json!({})).unwrap();
10367        // Rejected before any byte is written, so the session is not poisoned.
10368        assert!(session.update(boundary, &test_json!({ "a": 1 })).is_err());
10369        assert!(!session.is_finished());
10370        session
10371            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
10372            .unwrap();
10373        session.finish(&test_json!({})).unwrap();
10374    }
10375
10376    #[test]
10377    fn owned_streaming_session_recovers_from_a_rejected_finish() {
10378        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a", "comp-b"]));
10379        let handler = Arc::new(WebUIHandler::new());
10380        let mut session =
10381            StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
10382                .unwrap();
10383        let first = session.boundary("boundary-0").unwrap();
10384        let second = session.boundary("boundary-1").unwrap();
10385        session.write_shell(&test_json!({})).unwrap();
10386        session
10387            .write_boundary(first, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
10388            .unwrap();
10389
10390        // Rejected before any byte is written, so the response must survive.
10391        let error = session.finish(&test_json!({})).unwrap_err();
10392        assert!(
10393            error
10394                .to_string()
10395                .contains("every boundary must be committed"),
10396            "unexpected error: {error}"
10397        );
10398        assert!(!session.is_finished());
10399
10400        session
10401            .write_boundary(second, &test_json!({ "b_count": 1 }), BoundaryMode::Final)
10402            .unwrap();
10403        let tail = session.finish(&test_json!({})).unwrap();
10404        assert!(!tail.is_empty());
10405        assert!(session.is_finished());
10406    }
10407
10408    #[test]
10409    fn owned_streaming_session_rejects_finish_before_the_shell() {
10410        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"]));
10411        let handler = Arc::new(WebUIHandler::new());
10412        let mut session =
10413            StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
10414                .unwrap();
10415        let boundary = session.boundary("boundary-0").unwrap();
10416
10417        let error = session.finish(&test_json!({})).unwrap_err();
10418        assert!(
10419            error.to_string().contains("write_shell must be called"),
10420            "unexpected error: {error}"
10421        );
10422        assert!(!session.is_finished());
10423
10424        session.write_shell(&test_json!({})).unwrap();
10425        session
10426            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
10427            .unwrap();
10428        session.finish(&test_json!({})).unwrap();
10429        assert!(session.is_finished());
10430    }
10431
10432    #[test]
10433    fn streaming_response_is_poisoned_after_partial_boundary_flush_failure() {
10434        let protocol = disjoint_streaming_protocol(&["comp-a"]);
10435        let handler = WebUIHandler::new();
10436        let mut writer = FlushTestWriter {
10437            fail_flush_at: Some(1),
10438            ..FlushTestWriter::default()
10439        };
10440        let options = RenderOptions::new("index.html", "/");
10441        let mut response = handler
10442            .stream_response(&protocol, &options, &mut writer)
10443            .unwrap();
10444        let boundary = response.boundary("boundary-0").unwrap();
10445        response.write_shell(&test_json!({})).unwrap();
10446
10447        let first_error = response
10448            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
10449            .unwrap_err();
10450        assert!(matches!(first_error, HandlerError::ClientDisconnected));
10451
10452        let retry_error = response
10453            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
10454            .unwrap_err();
10455        assert!(
10456            retry_error
10457                .to_string()
10458                .contains("unusable after a previous render or transport failure"),
10459            "error: {retry_error}"
10460        );
10461
10462        drop(response);
10463        assert_eq!(writer.output.matches("<!--wb:0-->").count(), 1);
10464        assert_eq!(writer.flushes.len(), 1);
10465        assert!(!writer.ended);
10466    }
10467
10468    #[test]
10469    fn streaming_response_rejects_updates_to_final_boundaries() {
10470        let protocol = disjoint_streaming_protocol(&["comp-a"]);
10471        let handler = WebUIHandler::new();
10472        let mut writer = FlushTestWriter::default();
10473        let options = RenderOptions::new("index.html", "/");
10474        let mut response = handler
10475            .stream_response(&protocol, &options, &mut writer)
10476            .unwrap();
10477        let boundary = response.boundary("boundary-0").unwrap();
10478        response.write_shell(&test_json!({})).unwrap();
10479        response
10480            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
10481            .unwrap();
10482
10483        let error = response
10484            .update(boundary, &test_json!({ "a_count": 2 }))
10485            .unwrap_err();
10486        assert!(
10487            error.to_string().contains("committed as final"),
10488            "error: {error}"
10489        );
10490    }
10491
10492    #[test]
10493    fn streaming_response_rejects_non_object_updates_before_writing() {
10494        let protocol = disjoint_streaming_protocol(&["comp-a"]);
10495        let handler = WebUIHandler::new();
10496        let mut writer = FlushTestWriter::default();
10497        let options = RenderOptions::new("index.html", "/");
10498        let mut response = handler
10499            .stream_response(&protocol, &options, &mut writer)
10500            .unwrap();
10501        let boundary = response.boundary("boundary-0").unwrap();
10502        response.write_shell(&test_json!({})).unwrap();
10503        response
10504            .write_boundary(
10505                boundary,
10506                &test_json!({ "a_count": 1 }),
10507                BoundaryMode::Updatable,
10508            )
10509            .unwrap();
10510        let error = response
10511            .update(boundary, &test_json!("invalid"))
10512            .unwrap_err();
10513        assert!(error.to_string().contains("JSON object"), "error: {error}");
10514
10515        response
10516            .update(boundary, &test_json!({ "a_count": 2 }))
10517            .unwrap();
10518        response.finish(&test_json!({})).unwrap();
10519        assert_eq!(writer.flushes.len(), 4);
10520        assert_eq!(writer.output.matches("data-webui-boundary").count(), 3);
10521        assert!(writer.output.contains(r#"[1,1,2,0,{"a_count":2}]"#));
10522    }
10523
10524    #[test]
10525    fn streaming_response_unknown_boundary_suggests_valid_name() {
10526        let protocol = disjoint_streaming_protocol(&["comp-a"]);
10527        let handler = WebUIHandler::new();
10528        let mut writer = FlushTestWriter::default();
10529        let options = RenderOptions::new("index.html", "/");
10530        let response = handler
10531            .stream_response(&protocol, &options, &mut writer)
10532            .unwrap();
10533
10534        let error = response.boundary("boundry-0").unwrap_err();
10535        assert!(
10536            error.to_string().contains("did you mean `boundary-0`?"),
10537            "error: {error}"
10538        );
10539    }
10540
10541    fn streaming_root_validation_protocol(
10542        mut host_fragments: Vec<WebUIFragment>,
10543        tail: Vec<WebUIFragment>,
10544    ) -> Protocol {
10545        let mut entry = vec![
10546            WebUIFragment::raw("<html><head>"),
10547            structural_fragment("head_start"),
10548            structural_fragment("head_end"),
10549            WebUIFragment::raw("</head><body>"),
10550            structural_fragment("body_start"),
10551            structural_fragment("boundary_start:0"),
10552        ];
10553        entry.append(&mut host_fragments);
10554        entry.extend(tail);
10555        let fragments = HashMap::from([
10556            ("index.html".to_string(), FragmentList { fragments: entry }),
10557            (
10558                "comp-a".to_string(),
10559                FragmentList {
10560                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
10561                },
10562            ),
10563            (
10564                "comp-b".to_string(),
10565                FragmentList {
10566                    fragments: vec![WebUIFragment::raw("<b>B</b>")],
10567                },
10568            ),
10569        ]);
10570        Protocol::new(WebUIProtocol::new(fragments))
10571    }
10572
10573    fn completed_streaming_tail() -> Vec<WebUIFragment> {
10574        vec![
10575            WebUIFragment::raw("</comp-a>"),
10576            structural_fragment("boundary_end:0"),
10577            structural_fragment("body_end"),
10578            WebUIFragment::raw("</body></html>"),
10579        ]
10580    }
10581
10582    fn assert_streaming_root_error(
10583        protocol: &Protocol,
10584        expected_signal: &str,
10585        expected_reason: &str,
10586    ) {
10587        let mut writer = FlushTestWriter::default();
10588        let result = WebUIHandler::new().render_streaming(
10589            protocol,
10590            &test_json!({}),
10591            &RenderOptions::new("index.html", "/"),
10592            &mut writer,
10593        );
10594        match result {
10595            Err(HandlerError::StreamingBoundary(error)) => {
10596                assert_eq!(error.signal, expected_signal);
10597                assert!(
10598                    error.reason.contains(expected_reason),
10599                    "reason: {}",
10600                    error.reason
10601                );
10602            }
10603            other => panic!("expected streaming-root rejection, got {other:?}"),
10604        }
10605    }
10606
10607    #[test]
10608    fn streaming_root_signal_injects_data_ws_inside_boundary() {
10609        let protocol = disjoint_streaming_protocol(&["comp-a"]);
10610        let handler = WebUIHandler::with_plugin(|| {
10611            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10612        });
10613        let mut writer = FlushTestWriter::default();
10614        handler
10615            .render_streaming(
10616                &protocol,
10617                &test_json!({ "a_count": 1 }),
10618                &RenderOptions::new("index.html", "/"),
10619                &mut writer,
10620            )
10621            .unwrap();
10622        // The parser-owned signal is consumed to inject exactly ` data-ws`
10623        // inside the host's opening tag, before the custom element upgrades.
10624        assert!(
10625            writer.output.contains("<comp-a data-ws>"),
10626            "streamed host must carry data-ws: {}",
10627            writer.output
10628        );
10629    }
10630
10631    #[test]
10632    fn streaming_root_signal_preserves_ordinary_output_bytes() {
10633        // Ordinary rendering ignores `streaming_root` byte-for-byte: identical
10634        // output with and without the signal, and never a `data-ws` attribute.
10635        let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true);
10636        let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false);
10637        let state = test_json!({ "a_count": 1, "b_count": 2 });
10638        let plugin = || {
10639            WebUIHandler::with_plugin(
10640                || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
10641            )
10642        };
10643        let mut with_writer = TestWriter::new();
10644        let mut without_writer = TestWriter::new();
10645        plugin()
10646            .render(
10647                &with_signal,
10648                &state,
10649                &RenderOptions::new("index.html", "/"),
10650                &mut with_writer,
10651            )
10652            .unwrap();
10653        plugin()
10654            .render(
10655                &without_signal,
10656                &state,
10657                &RenderOptions::new("index.html", "/"),
10658                &mut without_writer,
10659            )
10660            .unwrap();
10661        // The rendered DOM (everything up to the inert data block) is
10662        // deterministic and is where a leaked `data-ws` would appear. The
10663        // ordinary template map is HashSet-ordered, so compare the DOM prefix
10664        // for byte identity rather than the whole document.
10665        let dom_prefix = |content: &str| -> String {
10666            content
10667                .split_once(r#"<script type="application/json" id="webui-data""#)
10668                .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
10669        };
10670        assert_eq!(
10671            dom_prefix(&with_writer.get_content()),
10672            dom_prefix(&without_writer.get_content())
10673        );
10674        assert!(!with_writer.get_content().contains("data-ws"));
10675        assert!(with_writer.get_content().contains("<comp-a>"));
10676    }
10677
10678    #[test]
10679    fn streaming_component_without_root_signal_is_rejected() {
10680        let protocol = streaming_root_validation_protocol(
10681            vec![
10682                WebUIFragment::raw("<comp-a>"),
10683                WebUIFragment::component("comp-a"),
10684            ],
10685            completed_streaming_tail(),
10686        );
10687
10688        assert_streaming_root_error(
10689            &protocol,
10690            "streaming_root:comp-a",
10691            "has no matching compiler-owned root signal",
10692        );
10693    }
10694
10695    #[test]
10696    fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
10697        let duplicate = streaming_root_validation_protocol(
10698            vec![
10699                WebUIFragment::raw("<comp-a"),
10700                structural_fragment("streaming_root:comp-a"),
10701                structural_fragment("streaming_root:comp-a"),
10702                WebUIFragment::raw(">"),
10703                WebUIFragment::component("comp-a"),
10704            ],
10705            completed_streaming_tail(),
10706        );
10707        assert_streaming_root_error(&duplicate, "streaming_root:comp-a", "is misplaced");
10708
10709        let after_tag_close = streaming_root_validation_protocol(
10710            vec![
10711                WebUIFragment::raw("<comp-a>"),
10712                structural_fragment("streaming_root:comp-a"),
10713                WebUIFragment::component("comp-a"),
10714            ],
10715            completed_streaming_tail(),
10716        );
10717        assert_streaming_root_error(&after_tag_close, "streaming_root:comp-a", "is misplaced");
10718
10719        let detached_from_opening = streaming_root_validation_protocol(
10720            vec![
10721                WebUIFragment::raw("not an opening tag"),
10722                structural_fragment("streaming_root:comp-a"),
10723                WebUIFragment::raw(">"),
10724                WebUIFragment::component("comp-a"),
10725            ],
10726            completed_streaming_tail(),
10727        );
10728        assert_streaming_root_error(
10729            &detached_from_opening,
10730            "streaming_root:comp-a",
10731            "unclosed component opening-tag close",
10732        );
10733    }
10734
10735    #[test]
10736    fn mismatched_streaming_root_signal_is_rejected() {
10737        let protocol = streaming_root_validation_protocol(
10738            vec![
10739                WebUIFragment::raw("<comp-a"),
10740                structural_fragment("streaming_root:comp-b"),
10741                WebUIFragment::raw(">"),
10742                WebUIFragment::component("comp-a"),
10743            ],
10744            completed_streaming_tail(),
10745        );
10746
10747        assert_streaming_root_error(
10748            &protocol,
10749            "streaming_root:comp-b",
10750            "opening host renders component <comp-a>",
10751        );
10752    }
10753
10754    #[test]
10755    fn pending_streaming_root_is_rejected_at_structural_ends() {
10756        let protocol = streaming_root_validation_protocol(
10757            vec![
10758                WebUIFragment::raw("<comp-a"),
10759                structural_fragment("streaming_root:comp-a"),
10760                WebUIFragment::raw(">"),
10761            ],
10762            vec![structural_fragment("boundary_end:0")],
10763        );
10764        assert_streaming_root_error(&protocol, "streaming_root:comp-a", "is misplaced");
10765    }
10766
10767    #[test]
10768    fn streaming_root_outside_boundary_is_rejected() {
10769        let fragments = HashMap::from([
10770            (
10771                "index.html".to_string(),
10772                FragmentList {
10773                    fragments: vec![
10774                        WebUIFragment::raw("<html><head>"),
10775                        structural_fragment("head_start"),
10776                        structural_fragment("head_end"),
10777                        WebUIFragment::raw("</head><body>"),
10778                        structural_fragment("body_start"),
10779                        WebUIFragment::raw("<comp-a"),
10780                        structural_fragment("streaming_root:comp-a"),
10781                        WebUIFragment::raw(">"),
10782                        WebUIFragment::component("comp-a"),
10783                        WebUIFragment::raw("</comp-a>"),
10784                        structural_fragment("body_end"),
10785                        WebUIFragment::raw("</body></html>"),
10786                    ],
10787                },
10788            ),
10789            (
10790                "comp-a".to_string(),
10791                FragmentList {
10792                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
10793                },
10794            ),
10795        ]);
10796        let protocol = Protocol::new(WebUIProtocol::new(fragments));
10797        let mut writer = FlushTestWriter::default();
10798        let result = WebUIHandler::new().render_streaming(
10799            &protocol,
10800            &test_json!({}),
10801            &RenderOptions::new("index.html", "/"),
10802            &mut writer,
10803        );
10804        match result {
10805            Err(HandlerError::StreamingBoundary(err)) => {
10806                assert_eq!(err.signal, "streaming_root:comp-a");
10807                assert!(
10808                    err.reason.contains("outside any <boundary>"),
10809                    "reason: {}",
10810                    err.reason
10811                );
10812            }
10813            other => panic!("expected outside-boundary rejection, got {other:?}"),
10814        }
10815    }
10816
10817    fn parser_route_protocol(with_boundary: bool) -> Protocol {
10818        let mut parser = HtmlParser::with_options(DomStrategy::Light);
10819        parser
10820            .component_registry_mut()
10821            .register_component(ComponentRegistration::new(
10822                "route-page",
10823                "<p>route content</p>",
10824                None,
10825                true,
10826            ))
10827            .expect("register route component");
10828        let route = r#"<route path="/" component="route-page" exact />"#;
10829        let html = if with_boundary {
10830            format!(
10831                "<html><head></head><body>\
10832                 <boundary name=\"route\">{route}</boundary>\
10833                 </body></html>"
10834            )
10835        } else {
10836            format!("<html><head></head><body>{route}</body></html>")
10837        };
10838        parser
10839            .parse("index.html", &html)
10840            .expect("parse route streaming fixture");
10841        let mut document = WebUIProtocol::new(parser.into_fragment_records());
10842        document.components.insert(
10843            "route-page".to_string(),
10844            webui_protocol::ComponentData::default(),
10845        );
10846        Protocol::new(document)
10847    }
10848
10849    #[test]
10850    fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
10851        let protocol = parser_route_protocol(true);
10852        let mut writer = FlushTestWriter::default();
10853        WebUIHandler::new()
10854            .render_streaming(
10855                &protocol,
10856                &test_json!({}),
10857                &RenderOptions::new("index.html", "/"),
10858                &mut writer,
10859            )
10860            .unwrap();
10861
10862        assert!(
10863            writer
10864                .output
10865                .contains("<route-page data-ws><p>route content</p></route-page>"),
10866            "matched route host must be deferred before upgrade: {}",
10867            writer.output
10868        );
10869    }
10870
10871    #[test]
10872    fn streaming_parser_route_host_outside_boundary_is_rejected() {
10873        let protocol = parser_route_protocol(false);
10874        let mut writer = FlushTestWriter::default();
10875        let result = WebUIHandler::new().render_streaming(
10876            &protocol,
10877            &test_json!({}),
10878            &RenderOptions::new("index.html", "/"),
10879            &mut writer,
10880        );
10881
10882        match result {
10883            Err(HandlerError::StreamingBoundary(error)) => {
10884                assert_eq!(error.signal, "streaming_root:route-page");
10885                assert!(error.reason.contains("outside any <boundary>"));
10886            }
10887            other => panic!("expected route boundary rejection, got {other:?}"),
10888        }
10889        assert!(
10890            !writer.output.contains("<route-page>"),
10891            "an unmarked custom-element opening must never be completed"
10892        );
10893    }
10894
10895    #[test]
10896    fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
10897        let entry = vec![
10898            WebUIFragment::raw("<html><head>"),
10899            structural_fragment("head_start"),
10900            structural_fragment("head_end"),
10901            WebUIFragment::raw("</head><body>"),
10902            structural_fragment("body_start"),
10903            structural_fragment("boundary_start:0"),
10904            WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
10905                path: "/account".into(),
10906                fragment_id: "account-shell".into(),
10907                exact: false,
10908                ..Default::default()
10909            }),
10910            structural_fragment("boundary_end:0"),
10911            structural_fragment("body_end"),
10912            WebUIFragment::raw("</body></html>"),
10913        ];
10914        let fragments = HashMap::from([
10915            ("index.html".to_string(), FragmentList { fragments: entry }),
10916            (
10917                "account-shell".to_string(),
10918                FragmentList {
10919                    fragments: vec![WebUIFragment::route_from(
10920                        webui_protocol::WebUiFragmentRoute {
10921                            path: "./details".into(),
10922                            fragment_id: "details-page".into(),
10923                            exact: true,
10924                            pending_component: "details-loading".into(),
10925                            error_component: "details-error".into(),
10926                            ..Default::default()
10927                        },
10928                    )],
10929                },
10930            ),
10931            (
10932                "details-page".to_string(),
10933                FragmentList {
10934                    fragments: vec![WebUIFragment::raw("<p>details</p>")],
10935                },
10936            ),
10937            (
10938                "details-loading".to_string(),
10939                FragmentList {
10940                    fragments: vec![WebUIFragment::raw("<p>loading</p>")],
10941                },
10942            ),
10943            (
10944                "details-error".to_string(),
10945                FragmentList {
10946                    fragments: vec![WebUIFragment::raw("<p>error</p>")],
10947                },
10948            ),
10949        ]);
10950        let mut document = WebUIProtocol::new(fragments);
10951        document.initial_state_strategy = InitialStateStrategy::Components as i32;
10952        for name in [
10953            "account-shell",
10954            "details-page",
10955            "details-loading",
10956            "details-error",
10957        ] {
10958            document.components.insert(
10959                name.to_string(),
10960                webui_protocol::ComponentData {
10961                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
10962                    ..Default::default()
10963                },
10964            );
10965        }
10966        let protocol = Protocol::new(document);
10967        let handler = WebUIHandler::with_plugin(|| {
10968            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10969        });
10970        let mut writer = FlushTestWriter::default();
10971
10972        handler
10973            .render_streaming(
10974                &protocol,
10975                &test_json!({}),
10976                &RenderOptions::new("index.html", "/account/details"),
10977                &mut writer,
10978            )
10979            .unwrap();
10980
10981        let first = &writer.output[..writer.flushes[0]];
10982        assert!(
10983            first.contains(r#""details-loading":"#),
10984            "relative-route pending metadata missing: {first}"
10985        );
10986        assert!(
10987            first.contains(r#""details-error":"#),
10988            "relative-route error metadata missing: {first}"
10989        );
10990    }
10991
10992    #[test]
10993    fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
10994        let entry = vec![
10995            WebUIFragment::raw("<html><head>"),
10996            structural_fragment("head_start"),
10997            structural_fragment("head_end"),
10998            WebUIFragment::raw("</head><body>"),
10999            structural_fragment("body_start"),
11000            structural_fragment("boundary_start:0"),
11001            WebUIFragment::raw("<static-shell"),
11002            structural_fragment("streaming_root:static-shell"),
11003            WebUIFragment::raw(">"),
11004            WebUIFragment::component("static-shell"),
11005            WebUIFragment::raw("</static-shell>"),
11006            WebUIFragment::raw("<route-shell"),
11007            structural_fragment("streaming_root:route-shell"),
11008            WebUIFragment::raw(">"),
11009            WebUIFragment::component("route-shell"),
11010            WebUIFragment::raw("</route-shell>"),
11011            structural_fragment("boundary_end:0"),
11012            structural_fragment("body_end"),
11013            WebUIFragment::raw("</body></html>"),
11014        ];
11015        let fragments = HashMap::from([
11016            ("index.html".to_string(), FragmentList { fragments: entry }),
11017            (
11018                "static-shell".to_string(),
11019                FragmentList {
11020                    fragments: vec![WebUIFragment::if_cond(
11021                        ConditionExpr::identifier("show_hidden"),
11022                        "static-hidden-if",
11023                    )],
11024                },
11025            ),
11026            (
11027                "static-hidden-if".to_string(),
11028                FragmentList {
11029                    fragments: vec![WebUIFragment::component("static-hidden")],
11030                },
11031            ),
11032            (
11033                "static-hidden".to_string(),
11034                FragmentList {
11035                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
11036                },
11037            ),
11038            (
11039                "route-shell".to_string(),
11040                FragmentList {
11041                    fragments: vec![WebUIFragment::route_from(
11042                        webui_protocol::WebUiFragmentRoute {
11043                            path: "/account".into(),
11044                            fragment_id: "route-page".into(),
11045                            exact: true,
11046                            ..Default::default()
11047                        },
11048                    )],
11049                },
11050            ),
11051            (
11052                "route-page".to_string(),
11053                FragmentList {
11054                    fragments: vec![WebUIFragment::raw("<p>route</p>")],
11055                },
11056            ),
11057        ]);
11058        let mut document = WebUIProtocol::new(fragments);
11059        document.set_css_strategy(webui_protocol::CssStrategy::Link);
11060        document.initial_state_strategy = InitialStateStrategy::Components as i32;
11061        for (name, keys) in [
11062            ("static-shell", vec!["show_hidden", "static_count"]),
11063            ("static-hidden", vec!["hidden_count"]),
11064            ("route-shell", vec!["route_shell_count"]),
11065            ("route-page", vec!["route_count"]),
11066        ] {
11067            document.components.insert(
11068                name.to_string(),
11069                webui_protocol::ComponentData {
11070                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
11071                    hydration_mode: StateProjectionMode::Keys as i32,
11072                    hydration_keys: keys.into_iter().map(str::to_string).collect(),
11073                    css_href: format!("/{name}.css"),
11074                    ..Default::default()
11075                },
11076            );
11077        }
11078        let protocol = Protocol::new(document);
11079        assert_eq!(
11080            protocol
11081                .component_reachability()
11082                .is_route_dependent(protocol.component_index()["route-shell"]),
11083            Some(true),
11084            "fixture must exercise request-aware checkpoint reachability"
11085        );
11086        let handler = WebUIHandler::with_plugin(|| {
11087            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11088        });
11089        let mut writer = FlushTestWriter::default();
11090
11091        handler
11092            .render_streaming(
11093                &protocol,
11094                &test_json!({
11095                    "show_hidden": false,
11096                    "static_count": 1,
11097                    "hidden_count": 2,
11098                    "route_shell_count": 3,
11099                    "route_count": 4,
11100                    "unrelated": "private",
11101                }),
11102                &RenderOptions::new("index.html", "/account"),
11103                &mut writer,
11104            )
11105            .unwrap();
11106
11107        let checkpoint = &writer.output[..writer.flushes[0]];
11108        for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
11109            assert!(
11110                checkpoint.contains(&format!(r#""{template}":"#)),
11111                "{template} metadata missing: {checkpoint}"
11112            );
11113        }
11114        for state in [
11115            r#""show_hidden":false"#,
11116            r#""static_count":1"#,
11117            r#""hidden_count":2"#,
11118            r#""route_shell_count":3"#,
11119            r#""route_count":4"#,
11120        ] {
11121            assert!(
11122                checkpoint.contains(state),
11123                "{state} state missing: {checkpoint}"
11124            );
11125        }
11126        assert!(
11127            !checkpoint.contains("unrelated"),
11128            "checkpoint leaked unrelated state: {checkpoint}"
11129        );
11130        assert!(
11131            checkpoint.contains(
11132                r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
11133            ),
11134            "checkpoint metadata lost source order: {checkpoint}"
11135        );
11136    }
11137
11138    #[test]
11139    fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
11140        // Three boundaries: comp-a, comp-b, then comp-a reused. Each checkpoint
11141        // envelope must be locally scoped — templates/state for exactly the
11142        // components rendered since the previous checkpoint, with reused tags
11143        // carrying hydration state but never a duplicate template.
11144        let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
11145        let handler = WebUIHandler::with_plugin(|| {
11146            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11147        });
11148        let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
11149        let mut writer = FlushTestWriter::default();
11150        handler
11151            .render_streaming(
11152                &protocol,
11153                &state,
11154                &RenderOptions::new("index.html", "/"),
11155                &mut writer,
11156            )
11157            .unwrap();
11158
11159        assert_eq!(
11160            writer.flushes.len(),
11161            4,
11162            "three boundary commits plus one terminal"
11163        );
11164        let segment = |index: usize| -> &str {
11165            let start = if index == 0 {
11166                0
11167            } else {
11168                writer.flushes[index - 1]
11169            };
11170            &writer.output[start..writer.flushes[index]]
11171        };
11172        let b0 = segment(0);
11173        let b1 = segment(1);
11174        let b2 = segment(2);
11175
11176        // Boundary 0: only comp-a's template/state; no comp-b artifacts.
11177        assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
11178        assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
11179        assert!(b0.contains(r#"[1,0,0,0,{"inventory":"01""#), "b0: {b0}");
11180        assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
11181        assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
11182
11183        // Boundary 1: only comp-b's template/state; no duplicate comp-a template.
11184        assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
11185        assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
11186        assert!(b1.contains(r#"[1,1,0,1,{"inventory":"02""#), "b1: {b1}");
11187        assert!(
11188            !b1.contains(r#""templates":{"comp-a"#),
11189            "b1 re-sent comp-a: {b1}"
11190        );
11191        assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
11192
11193        // Boundary 2: comp-a reused — state present, template absent (empty delta).
11194        assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
11195        assert!(b2.contains(r#"[1,2,0,2,{"inventory":"""#), "b2: {b2}");
11196        assert!(
11197            !b2.contains(r#""templates""#),
11198            "b2 re-sent a template: {b2}"
11199        );
11200
11201        // Server-only state never leaks into any envelope.
11202        assert!(!writer.output.contains("serverOnly"));
11203    }
11204
11205    #[test]
11206    fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
11207        let entry = vec![
11208            WebUIFragment::raw("<html><head>"),
11209            structural_fragment("head_start"),
11210            structural_fragment("head_end"),
11211            WebUIFragment::raw("</head><body>"),
11212            structural_fragment("body_start"),
11213            structural_fragment("boundary_start:0"),
11214            WebUIFragment::raw("<comp-a"),
11215            structural_fragment("streaming_root:comp-a"),
11216            WebUIFragment::raw(">"),
11217            WebUIFragment::component("comp-a"),
11218            WebUIFragment::raw("</comp-a>"),
11219            structural_fragment("boundary_end:0"),
11220            structural_fragment("boundary_start:1"),
11221            WebUIFragment::raw("<comp-hidden"),
11222            structural_fragment("streaming_root:comp-hidden"),
11223            WebUIFragment::raw(">"),
11224            WebUIFragment::component("comp-hidden"),
11225            WebUIFragment::raw("</comp-hidden>"),
11226            structural_fragment("boundary_end:1"),
11227            structural_fragment("body_end"),
11228            WebUIFragment::raw("</body></html>"),
11229        ];
11230        let fragments = HashMap::from([
11231            ("index.html".to_string(), FragmentList { fragments: entry }),
11232            (
11233                "comp-a".to_string(),
11234                FragmentList {
11235                    fragments: vec![WebUIFragment::if_cond(
11236                        ConditionExpr::identifier("show_hidden"),
11237                        "hidden-if",
11238                    )],
11239                },
11240            ),
11241            (
11242                "hidden-if".to_string(),
11243                FragmentList {
11244                    fragments: vec![WebUIFragment::component("comp-hidden")],
11245                },
11246            ),
11247            (
11248                "comp-hidden".to_string(),
11249                FragmentList {
11250                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
11251                },
11252            ),
11253        ]);
11254        let mut document = WebUIProtocol::new(fragments);
11255        document.initial_state_strategy = InitialStateStrategy::Components as i32;
11256        document.components.insert(
11257            "comp-a".to_string(),
11258            webui_protocol::ComponentData {
11259                template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
11260                hydration_mode: StateProjectionMode::Keys as i32,
11261                hydration_keys: vec!["show_hidden".to_string()],
11262                ..Default::default()
11263            },
11264        );
11265        document.components.insert(
11266            "comp-hidden".to_string(),
11267            webui_protocol::ComponentData {
11268                template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
11269                css: ".hidden{display:block}".to_string(),
11270                hydration_mode: StateProjectionMode::Keys as i32,
11271                hydration_keys: vec!["hidden_count".to_string()],
11272                ..Default::default()
11273            },
11274        );
11275        let protocol = Protocol::new(document);
11276        let handler = WebUIHandler::with_plugin(|| {
11277            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11278        });
11279        let mut writer = FlushTestWriter::default();
11280
11281        handler
11282            .render_streaming(
11283                &protocol,
11284                &test_json!({
11285                    "show_hidden": false,
11286                    "hidden_count": 7,
11287                    "unrelated": "private",
11288                }),
11289                &RenderOptions::new("index.html", "/"),
11290                &mut writer,
11291            )
11292            .unwrap();
11293
11294        let boundary = |index: usize| -> &str {
11295            let start = if index == 0 {
11296                0
11297            } else {
11298                writer.flushes[index - 1]
11299            };
11300            &writer.output[start..writer.flushes[index]]
11301        };
11302        let first = boundary(0);
11303        let second = boundary(1);
11304
11305        assert!(first.contains(r#""comp-a":"#), "first: {first}");
11306        assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
11307        assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
11308        assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
11309        assert!(first.contains(r#""inventory":"01""#), "first: {first}");
11310        assert!(
11311            first.contains(r#"<script type="importmap">"#),
11312            "first: {first}"
11313        );
11314        assert!(
11315            !first.contains("unrelated"),
11316            "first leaked unrelated state: {first}"
11317        );
11318
11319        assert!(
11320            !second.contains(r#""templates""#),
11321            "second re-sent metadata: {second}"
11322        );
11323        assert!(
11324            !second.contains(r#"<script type="importmap">"#),
11325            "second re-sent CSS: {second}"
11326        );
11327        assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
11328        assert!(second.contains(r#""inventory":"02""#), "second: {second}");
11329        assert_eq!(
11330            writer
11331                .output
11332                .matches(r#"<script type="importmap">"#)
11333                .count(),
11334            1
11335        );
11336    }
11337
11338    #[test]
11339    fn streaming_flush_count_is_boundaries_plus_one() {
11340        for &count in &[1usize, 3, 10, 100] {
11341            let hosts: Vec<&str> = (0..count)
11342                .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
11343                .collect();
11344            let protocol = disjoint_streaming_protocol(&hosts);
11345            let handler = WebUIHandler::with_plugin(|| {
11346                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11347            });
11348            let mut writer = FlushTestWriter::default();
11349            handler
11350                .render_streaming(
11351                    &protocol,
11352                    &test_json!({ "a_count": 1, "b_count": 2 }),
11353                    &RenderOptions::new("index.html", "/"),
11354                    &mut writer,
11355                )
11356                .unwrap();
11357            assert_eq!(
11358                writer.flushes.len(),
11359                count + 1,
11360                "expected boundaries+1 flushes for {count} boundaries"
11361            );
11362        }
11363    }
11364
11365    #[test]
11366    fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
11367        let mut protocol = WebUIProtocol::new(HashMap::new());
11368        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11369        protocol.components.insert(
11370            "comp-a".to_string(),
11371            webui_protocol::ComponentData {
11372                hydration_mode: StateProjectionMode::Keys as i32,
11373                hydration_keys: vec!["a".to_string(), "shared".to_string()],
11374                ..Default::default()
11375            },
11376        );
11377        protocol.components.insert(
11378            "comp-b".to_string(),
11379            webui_protocol::ComponentData {
11380                hydration_mode: StateProjectionMode::Keys as i32,
11381                hydration_keys: vec!["b".to_string(), "shared".to_string()],
11382                ..Default::default()
11383            },
11384        );
11385
11386        let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
11387        assert!(!collect_hydration_state_into(
11388            &protocol,
11389            ["comp-a"],
11390            &mut scratch,
11391        ));
11392        assert_eq!(scratch, ["a", "shared"]);
11393        let pointer = scratch.as_ptr();
11394        let capacity = scratch.capacity();
11395
11396        assert!(!collect_hydration_state_into(
11397            &protocol,
11398            ["comp-b"],
11399            &mut scratch,
11400        ));
11401        assert_eq!(scratch, ["b", "shared"]);
11402        assert_eq!(scratch.as_ptr(), pointer);
11403        assert_eq!(scratch.capacity(), capacity);
11404    }
11405}