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