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