Skip to main content

webui_handler/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! WebUI Handler implementation for Rust.
5//!
6//! This crate provides functionality to process and render WebUI protocols
7//! into final HTML output based on provided data.
8
9pub mod css_module;
10pub(crate) mod html_encode;
11pub mod plugin;
12pub mod route_handler;
13pub mod route_matcher;
14pub(crate) mod route_renderer;
15pub(crate) mod streaming;
16
17pub use route_handler::Protocol;
18
19/// Minimal HTML escaper for the 6 XSS-critical characters
20/// (`& < > " ' /`). Returns `Cow::Borrowed` when no escaping is
21/// needed (zero allocation on the happy path), `Cow::Owned` when
22/// any character had to be replaced.
23///
24/// Re-exported here so external callers of `RenderOptions::with_head_inject`
25/// / `with_body_inject` can pre-escape untrusted content with the
26/// same escaper the handler uses internally for SSR text content,
27/// without having to pull in a separate HTML-escape crate.
28pub use html_encode::encode_safe;
29
30use plugin::BootstrapExtensionContext;
31use plugin::HandlerPlugin;
32use plugin::WebUiTemplatePayload;
33use route_matcher::CompiledRouteIndex;
34use serde::ser::SerializeMap;
35use serde::Serialize;
36use serde_json::Value;
37use std::borrow::Cow;
38use std::collections::{HashMap, HashSet};
39use streaming::{
40    consume_streaming_component_root, ensure_no_pending_streaming_root,
41    prepare_generated_streaming_root, record_checkpoint_tag, streaming_template_already_sent,
42    validate_pending_streaming_root, validate_streaming_root_opening, ComponentHostOrigin,
43    StreamingRenderState,
44};
45pub use streaming::{
46    BoundaryId, BoundaryMode, BufferSink, SessionOptions, StreamingResponse, StreamingSession,
47};
48use thiserror::Error;
49use webui_expressions::{evaluate_with_resolver, ExpressionError};
50use webui_protocol::{
51    web_ui_fragment::Fragment, InitialStateStrategy, StateProjectionMode, WebUIFragment,
52    WebUIProtocol,
53};
54use webui_state::find_value_by_dotted_path_ref;
55
56/// Error types for the WebUI handler.
57#[derive(Debug, Error)]
58pub enum HandlerError {
59    #[error("Rendering error: {0}")]
60    Rendering(String),
61
62    #[error("Rendering invariant error: {0}")]
63    Invariant(String),
64
65    #[error("Missing fragment: {0}")]
66    MissingFragment(String),
67
68    #[error("Missing data field: {0}")]
69    MissingData(String),
70
71    #[error("Type error: {0}")]
72    TypeError(String),
73
74    #[error("Protocol error: {0}")]
75    Protocol(#[from] webui_protocol::ProtocolError),
76
77    #[error("Evaluation error: {0}")]
78    Evaluation(String),
79
80    #[error("I/O error: {0}")]
81    Io(#[from] std::io::Error),
82
83    #[error("Writer error: {0}")]
84    Writer(String),
85
86    #[error("Plugin data error: {0}")]
87    PluginData(String),
88
89    /// The HTTP client disconnected before the render completed.
90    ///
91    /// Streaming `ResponseWriter` implementations return this from
92    /// `write()` once their channel/socket is closed, so the handler
93    /// can abort the render rather than do CPU work that has nowhere
94    /// to go. Allocation-free (the variant carries no payload).
95    #[error("client disconnected")]
96    ClientDisconnected,
97
98    /// The streaming writer's flush exceeded its configured deadline.
99    ///
100    /// Indicates a slow/unresponsive consumer (slow-loris client,
101    /// stuck proxy, etc.). The render thread is freed; downstream
102    /// telemetry should distinguish this from `ClientDisconnected`
103    /// so ops can alert on slow-client attacks.
104    #[error("streaming flush timed out")]
105    StreamTimeout,
106
107    /// A streaming-boundary signal was malformed or arrived out of order.
108    ///
109    /// The payload is boxed so this cold, streaming-only variant does not
110    /// widen [`HandlerError`] — and therefore `Result<(), HandlerError>`
111    /// threaded through the entire hot legacy render path — with two inline
112    /// `String`s.
113    #[error("invalid streaming boundary signal `{}`: {}", .0.signal, .0.reason)]
114    StreamingBoundary(Box<StreamingBoundaryError>),
115
116    /// A streaming render ended without its structural `body_end` signal.
117    #[error("streaming render ended before `body_end`; no terminal boundary record was emitted")]
118    MissingStreamingBodyEnd,
119
120    /// Streaming initialization cannot be emitted before document content.
121    #[error(
122        "streaming protocol is missing the required `head_start` signal before `{before}`; \
123         rebuild the protocol with streaming-boundary parser support"
124    )]
125    MissingStreamingHeadStart {
126        /// Structural point that made initialization too late.
127        before: &'static str,
128    },
129
130    /// A malformed protocol emitted streaming initialization more than once.
131    #[error("streaming protocol emitted duplicate `head_start` signals")]
132    DuplicateStreamingHeadStart,
133}
134
135/// Boxed payload for [`HandlerError::StreamingBoundary`].
136///
137/// Kept behind a `Box` so the streaming-validation variant contributes only a
138/// pointer-sized payload to [`HandlerError`], keeping the common
139/// `Result<(), HandlerError>` small on the ordinary render path where these
140/// boundary errors never occur.
141#[derive(Debug)]
142pub struct StreamingBoundaryError {
143    /// The logical structural token (the internal namespace is stripped).
144    pub signal: String,
145    /// Actionable validation failure.
146    pub reason: String,
147}
148
149pub type Result<T> = std::result::Result<T, HandlerError>;
150
151#[cold]
152#[inline(never)]
153fn invalid_fragment_range_error(
154    range: &std::ops::Range<usize>,
155    fragment_count: usize,
156) -> HandlerError {
157    HandlerError::Invariant(format!(
158        "fragment range {}..{} exceeds fragment count {fragment_count}",
159        range.start, range.end
160    ))
161}
162
163/// Interface for writing rendered output
164pub trait ResponseWriter {
165    /// Write content to the output
166    fn write(&mut self, content: &str) -> Result<()>;
167
168    /// Finalize the output
169    fn end(&mut self) -> Result<()>;
170
171    /// Hand buffered bytes to the transport at a committed streaming boundary.
172    ///
173    /// Internal hook used only by the progressive streaming render path so the
174    /// request-local sink can flush its concrete transport without a shared-cell
175    /// borrow or a second virtual dispatch on every write. Ordinary writers keep
176    /// the no-op default; hosts must not override or depend on this — implement
177    /// [`FlushWriter`] and use [`WebUIHandler::render_streaming`] instead.
178    #[doc(hidden)]
179    fn stream_flush(&mut self) -> Result<()> {
180        Ok(())
181    }
182}
183
184/// A response writer that can hand buffered bytes to its transport immediately.
185///
186/// Progressive hydration requires this semantic flush at every committed
187/// boundary. Hosts that cannot provide it must use [`WebUIHandler::render`].
188pub trait FlushWriter: ResponseWriter {
189    /// Hand all currently buffered bytes to the underlying transport.
190    fn flush(&mut self) -> Result<()>;
191}
192
193/// Options controlling how the handler renders a protocol.
194///
195/// The handler performs server-side route matching: matched routes are rendered
196/// visible with content; non-matched routes are rendered hidden and empty.
197pub struct RenderOptions<'a> {
198    /// The fragment ID to start rendering from (e.g., `"index.html"`).
199    pub entry_id: &'a str,
200    /// The URL path to match routes against (e.g., `"/contacts/42"`).
201    pub request_path: &'a str,
202    /// Optional CSP nonce for inline `<script>` tags.
203    /// When set, all inline scripts include `nonce="VALUE"` and a
204    /// `<meta name="webui-nonce">` tag is emitted for the client router.
205    pub nonce: Option<&'a str>,
206    /// Optional HTML to emit immediately before the document's
207    /// `</head>` close. Used for per-request `<link rel="preload">`
208    /// hints, CSP `<meta>` tags beyond the built-in nonce, etc.
209    /// Inserted at the structural `head_end` boundary identified by
210    /// the parser — never matched against a byte pattern, so cannot
211    /// be tricked by `</head>` literals appearing in HTML comments,
212    /// `srcdoc` attributes, or inline scripts.
213    pub head_inject: Option<&'a str>,
214    /// Optional HTML to emit immediately before the document's
215    /// `</body>` close. Used for dev livereload `<script>`, analytics
216    /// snippets, OpenTelemetry trace IDs, etc.
217    /// Same structural-boundary guarantee as [`head_inject`](Self::head_inject).
218    pub body_inject: Option<&'a str>,
219}
220
221/// Reserved top-level state key carrying host-supplied boundary HTML.
222///
223/// The leading `$` marks this key as reserved host metadata rather than
224/// ordinary application state.
225///
226/// Recognized members, each an optional string:
227///
228/// | Member | Emitted at |
229/// | --- | --- |
230/// | `headEnd` | immediately before `</head>` |
231/// | `bodyStart` | immediately after `<body>` |
232/// | `bodyEnd` | immediately before `</body>` |
233///
234/// The key is always honored — it is part of the render state the host
235/// already supplies — and is stripped from the client hydration payload.
236pub const STATE_INJECT_KEY: &str = "$webui";
237
238/// Host-supplied boundary HTML resolved once per render from
239/// [`STATE_INJECT_KEY`].
240///
241/// Resolution happens when the render context is built, so each structural
242/// hook costs one `Option` check instead of a state map lookup. Every field
243/// borrows from the caller's state value — no clone, no allocation.
244#[derive(Clone, Copy, Default)]
245pub(crate) struct StateInject<'state> {
246    pub(crate) head_end: Option<&'state str>,
247    pub(crate) body_start: Option<&'state str>,
248    pub(crate) body_end: Option<&'state str>,
249}
250
251impl<'state> StateInject<'state> {
252    /// Resolve the reserved namespace from a render state value.
253    ///
254    /// Returns the empty set when the key is absent, or when any member is
255    /// missing, null, empty, or not a string. Malformed input is inert rather than an error: the reserved
256    /// key is an optional side channel, and a render must not fail because a
257    /// host wrote the wrong shape into it.
258    pub(crate) fn resolve(state: &'state Value) -> Self {
259        let Some(Value::Object(map)) = state.get(STATE_INJECT_KEY) else {
260            return Self::default();
261        };
262        let field = |name: &str| {
263            map.get(name)
264                .and_then(Value::as_str)
265                .filter(|html| !html.is_empty())
266        };
267        Self {
268            head_end: field("headEnd"),
269            body_start: field("bodyStart"),
270            body_end: field("bodyEnd"),
271        }
272    }
273}
274
275impl<'a> RenderOptions<'a> {
276    /// Create render options for the given entry fragment and request path.
277    #[must_use]
278    pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
279        Self {
280            entry_id,
281            request_path,
282            nonce: None,
283            head_inject: None,
284            body_inject: None,
285        }
286    }
287
288    /// Set the CSP nonce for inline scripts. Pass an empty string to
289    /// disable (`None` semantics) — empty `<meta name="webui-nonce"
290    /// content="">` would be browser-ignored noise.
291    #[must_use]
292    pub fn with_nonce(mut self, nonce: &'a str) -> Self {
293        self.nonce = if nonce.is_empty() { None } else { Some(nonce) };
294        self
295    }
296
297    /// Set HTML to emit immediately before `</head>`.
298    /// Pass an empty string to disable (`None` semantics).
299    ///
300    /// # Safety (XSS warning)
301    ///
302    /// The provided HTML is written verbatim — **no HTML escaping is
303    /// performed**. Callers MUST ensure the content is fully trusted
304    /// (typically a `&'static str` or build-time-derived bytes such as
305    /// dev livereload script, image preload `<link>` tags, or A/B test
306    /// markers). Passing user-controlled or attacker-influenced content
307    /// here is a direct cross-site scripting vulnerability. If your
308    /// caller path may include untrusted data, escape with the host's
309    /// HTML escaper (e.g. [`webui_handler::encode_safe`](crate::encode_safe))
310    /// **before** calling this builder.
311    #[must_use]
312    pub fn with_head_inject(mut self, html: &'a str) -> Self {
313        self.head_inject = if html.is_empty() { None } else { Some(html) };
314        self
315    }
316
317    /// Set HTML to emit immediately before `</body>`.
318    /// Pass an empty string to disable (`None` semantics).
319    ///
320    /// # Safety (XSS warning)
321    ///
322    /// Same contract as [`with_head_inject`](Self::with_head_inject):
323    /// the HTML is written verbatim with **no escaping**, so callers
324    /// MUST ensure the content is fully trusted. Untrusted content is
325    /// a direct XSS vector.
326    #[must_use]
327    pub fn with_body_inject(mut self, html: &'a str) -> Self {
328        self.body_inject = if html.is_empty() { None } else { Some(html) };
329        self
330    }
331}
332
333/// The main WebUI handler that processes protocols and renders them.
334///
335/// The handler is stateless: plugin instances are created per-render from
336/// the stored factory function, allowing concurrent renders with `&self`.
337pub struct WebUIHandler {
338    plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
339}
340
341/// Context object for processing WebUI fragments
342pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> {
343    pub(crate) protocol: &'protocol WebUIProtocol,
344    pub(crate) state: &'state Value,
345    pub(crate) writer: &'output mut dyn ResponseWriter,
346    pub(crate) local_vars: HashMap<String, Value>,
347    /// Accumulates component attribute values between attrStart and the component fragment.
348    pub(crate) component_attrs: HashMap<String, Value>,
349    /// URL path for server-side route matching. Borrowed from
350    /// `RenderOptions<'a>::request_path` — zero-copy.
351    pub(crate) request_path: &'protocol str,
352    /// Base path for resolving relative route paths (`./`).
353    /// Updated as the handler descends into nested matched routes.
354    /// `Cow` keeps the initial `"/"` literal zero-copy; nested-route
355    /// descent owns the recomputed path.
356    pub(crate) route_base: Cow<'protocol, str>,
357    /// Component names visited during rendering (for selective f-template emission
358    /// and CSS module dedup — only the first render of each component emits
359    /// its `<script type="importmap">` data-URI tag).
360    pub(crate) rendered_components: HashSet<String>,
361    /// Per-render plugin instance created from the handler's factory.
362    pub(crate) plugin: Option<Box<dyn HandlerPlugin>>,
363    /// Current position in the route tree for outlet-based rendering.
364    /// Contains the children of the currently matched route fragment.
365    pub(crate) route_children: Vec<webui_protocol::WebUiFragmentRoute>,
366    /// Entry fragment ID — used to compute the initial inventory at head_end.
367    /// Borrowed from `RenderOptions<'a>::entry_id` — zero-copy.
368    pub(crate) entry_id: &'protocol str,
369    /// CSP nonce for inline `<script>` tags (None = no nonce attribute).
370    /// Borrowed from `RenderOptions<'a>::nonce` — zero-copy.
371    pub(crate) nonce: Option<&'protocol str>,
372    /// Component-name → bit-position map built once when the runtime
373    /// [`Protocol`] is created and shared by every render.
374    pub(crate) component_index: &'protocol HashMap<String, u32>,
375    /// HTML emitted at the structural `head_end` boundary (before
376    /// `</head>`), after the built-in nonce/CSS-preload emissions.
377    /// Zero-copy borrow of the caller's `RenderOptions<'a>::head_inject`
378    /// (no per-render clone — saves an allocation when the host passes
379    /// a `&'static str` such as a dev livereload script).
380    pub(crate) head_inject: Option<&'protocol str>,
381    /// HTML emitted at the structural `body_end` boundary (before
382    /// `</body>`), after the built-in template metadata emissions.
383    /// Same zero-copy borrow as [`head_inject`](Self::head_inject).
384    pub(crate) body_inject: Option<&'protocol str>,
385    /// Host HTML resolved once per render from the reserved
386    /// [`STATE_INJECT_KEY`] state namespace. Emitted after the built-in
387    /// emissions at each structural boundary, and after the corresponding
388    /// `RenderOptions` inject at `head_end` and `body_end`.
389    pub(crate) state_inject: StateInject<'state>,
390    /// Tracks whether the `head_end` hook has already fired in this
391    /// render. Defends against malformed protocols that emit the
392    /// signal more than once (e.g., a template with multiple `<head>`
393    /// tags) — without this, host-supplied `head_inject` HTML, CSS
394    /// preload `<link>` tags, and the CSP `<meta>` nonce would be
395    /// duplicated, which can be a CSP-bypass / cache-bloat vector.
396    pub(crate) head_end_emitted: bool,
397    /// Tracks whether the `body_start` hook has already fired in this
398    /// render. Defends against malformed protocols emitting the signal
399    /// twice — without this, state-supplied `bodyStart` HTML would be
400    /// duplicated.
401    pub(crate) body_start_emitted: bool,
402    /// Tracks whether the `body_end` hook has already fired in this
403    /// render. Defends against malformed protocols emitting the
404    /// signal twice — without this, hydration `<script>` blocks and
405    /// host-supplied `body_inject` would be duplicated.
406    pub(crate) body_end_emitted: bool,
407    /// Immutable authored route patterns compiled when [`Protocol`] is loaded.
408    pub(crate) route_index: &'protocol CompiledRouteIndex,
409    /// Counter for `data-ri` attributes on matched route elements.
410    /// Incremented each time a matched route is rendered, allowing O(1) element
411    /// binding on the client side instead of DOM-walking.
412    pub(crate) route_chain_index: usize,
413    /// Present only for the opt-in progressive streaming render path.
414    pub(crate) streaming: Option<&'output mut StreamingRenderState<'protocol>>,
415    /// Reusable JSON serialization scratch buffer, owned by the render context.
416    /// The bootstrap/field/template helpers borrow it so each render reuses one
417    /// buffer across every serialized field and every streaming checkpoint
418    /// instead of allocating per value. It grows lazily on first serialization
419    /// and is dropped with the context at request end — no per-thread
420    /// high-water buffer is retained between requests.
421    pub(crate) json_scratch: Vec<u8>,
422    /// Small request-local pool of cleared scope maps reused across sibling
423    /// component roots. `process_component` recycles each finished component's
424    /// local/attr map here instead of dropping it, so a sibling reuses the
425    /// bucket capacity rather than reallocating a fresh `HashMap`. Bounded
426    /// ([`SCOPE_POOL_CAP`]) and dropped with the context at request end.
427    pub(crate) scope_pool: Vec<HashMap<String, Value>>,
428}
429
430/// Compiler-owned signal namespace. The leading `}}}` cannot be produced by
431/// authored double- or triple-brace expressions because it closes the binding.
432pub(crate) const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
433
434pub(crate) fn structural_signal_value(
435    signal: &webui_protocol::WebUIFragmentSignal,
436) -> Option<&str> {
437    if !signal.raw {
438        return None;
439    }
440    signal.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
441}
442
443/// Maximum scope maps retained in the request-local pool. Small: sibling
444/// component roots rarely nest deeply, so the cap keeps retained capacity bounded.
445const SCOPE_POOL_CAP: usize = 8;
446
447/// Take a cleared scope map from the pool, or a fresh empty one when the pool is
448/// empty. A fresh `HashMap` does not allocate until its first insert.
449fn take_scope_map(pool: &mut Vec<HashMap<String, Value>>) -> HashMap<String, Value> {
450    pool.pop().unwrap_or_default()
451}
452
453/// Return a spent scope map to the pool, clearing it but retaining its bucket
454/// capacity for a sibling root to reuse. Drops the map once the pool is full so
455/// retained memory stays bounded.
456fn recycle_scope_map(pool: &mut Vec<HashMap<String, Value>>, mut map: HashMap<String, Value>) {
457    if pool.len() < SCOPE_POOL_CAP {
458        map.clear();
459        pool.push(map);
460    }
461}
462
463pub(crate) struct WebUiBootstrap<'a> {
464    pub(crate) state: &'a Value,
465    pub(crate) state_selection: StateSelection<'a>,
466    pub(crate) chain: &'a [Value],
467    pub(crate) inventory: &'a str,
468    pub(crate) nonce: Option<&'a str>,
469    pub(crate) css_hrefs: &'a [&'a str],
470    pub(crate) style_specs: &'a [&'a str],
471    pub(crate) templates: &'a [WebUiTemplatePayload<'a>],
472}
473
474/// Get the component attribute name, stripping `:` prefix and converting to camelCase.
475///
476/// Uses `webui_protocol::attrs::attribute_to_camel` which handles irregular
477/// attributes (multi-word ARIA and global HTML attributes like `readonly`,
478/// `tabindex`) via the shared lookup table.
479fn component_attr_name(name: &str) -> String {
480    let stripped = name.strip_prefix(':').unwrap_or(name);
481    webui_protocol::attrs::attribute_to_camel(stripped)
482}
483
484/// Write a usize as decimal digits directly to the writer, avoiding `format!` allocation.
485pub(crate) fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
486    if n == 0 {
487        return writer.write("0");
488    }
489    // Max digits for a 64-bit usize is 20.
490    let mut buf = [0u8; 20];
491    let mut pos = buf.len();
492    while n > 0 {
493        pos -= 1;
494        // n % 10 is always in 0..=9, fits in u8 without truncation.
495        #[allow(clippy::cast_possible_truncation)]
496        let digit = (n % 10) as u8;
497        buf[pos] = b'0' + digit;
498        n /= 10;
499    }
500    // Digits are always valid ASCII/UTF-8.
501    match std::str::from_utf8(&buf[pos..]) {
502        Ok(s) => writer.write(s),
503        Err(_) => writer.write("0"),
504    }
505}
506
507pub(crate) fn write_script_safe_json<T>(
508    writer: &mut dyn ResponseWriter,
509    scratch: &mut Vec<u8>,
510    value: &T,
511) -> Result<()>
512where
513    T: Serialize + ?Sized,
514{
515    // Serialize into the caller's request-local `scratch`. The streaming path
516    // emits one bootstrap envelope per committed boundary and the ordinary
517    // body_end bootstrap serializes several fields, so reusing one buffer across
518    // the render avoids a fresh allocation per value. The buffer grows lazily on
519    // first use (no allocation until serialization needs it) and is dropped with
520    // the render context — capacity is reused within a request but never
521    // retained across requests.
522    scratch.clear();
523    serde_json::to_writer(&mut *scratch, value)
524        .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
525    let json = std::str::from_utf8(scratch)
526        .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
527    write_script_safe_json_str(writer, json)
528}
529
530fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
531    let mut start = 0;
532    while start < json.len() {
533        let rest = &json[start..];
534        let Some(offset) = rest.find("</") else {
535            writer.write(rest)?;
536            return Ok(());
537        };
538
539        if offset > 0 {
540            writer.write(&rest[..offset])?;
541        }
542        writer.write("<\\/")?;
543        start += offset + 2;
544    }
545    Ok(())
546}
547
548fn write_json_field_name(
549    writer: &mut dyn ResponseWriter,
550    wrote_field: &mut bool,
551    name: &str,
552) -> Result<()> {
553    if *wrote_field {
554        writer.write(",")?;
555    }
556    *wrote_field = true;
557    writer.write("\"")?;
558    writer.write(name)?;
559    writer.write("\":")
560}
561
562fn write_json_field<T>(
563    writer: &mut dyn ResponseWriter,
564    scratch: &mut Vec<u8>,
565    wrote_field: &mut bool,
566    name: &str,
567    value: &T,
568) -> Result<()>
569where
570    T: Serialize + ?Sized,
571{
572    write_json_field_name(writer, wrote_field, name)?;
573    write_script_safe_json(writer, scratch, value)
574}
575
576/// Serialize wrapper that projects an SSR state object down to only the
577/// keys present in the build-time hydration allowlist.
578///
579/// This is the runtime half of the projected-hydration design: instead of
580/// serializing the entire application state (potentially megabytes) on every
581/// full-HTML render, only the fields a component actually hydrates are
582/// emitted. The request allowlist conservatively includes every reachable
583/// component's hydration keys so no field a component needs is dropped.
584///
585/// Projection is a payload boundary, not a secrecy boundary. Any key selected
586/// by compiled client metadata is browser-facing, so hosts must never place
587/// secrets in browser render state.
588///
589/// `keys` MUST be sorted and deduplicated. Projection iterates whichever side
590/// is smaller: hydration keys with direct map lookup for wide states, or state
591/// entries with binary-search membership for compact states. Non-object states
592/// carry nothing hydratable and serialize as an empty object.
593struct ProjectedState<'a> {
594    value: &'a Value,
595    keys: &'a [&'a str],
596}
597
598impl Serialize for ProjectedState<'_> {
599    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
600    where
601        S: serde::Serializer,
602    {
603        let Value::Object(map) = self.value else {
604            return serializer.serialize_map(Some(0))?.end();
605        };
606
607        let mut out = serializer.serialize_map(None)?;
608        if self.keys.len() < map.len() {
609            let mut previous = None;
610            for key in self.keys {
611                if *key == STATE_INJECT_KEY {
612                    continue;
613                }
614                if previous == Some(*key) {
615                    continue;
616                }
617                previous = Some(*key);
618                if let Some(value) = map.get(*key) {
619                    out.serialize_entry(key, value)?;
620                }
621            }
622        } else {
623            for (key, value) in map {
624                if key == STATE_INJECT_KEY {
625                    continue;
626                }
627                if self
628                    .keys
629                    .binary_search_by(|candidate| candidate.cmp(&key.as_str()))
630                    .is_ok()
631                {
632                    out.serialize_entry(key, value)?;
633                }
634            }
635        }
636        out.end()
637    }
638}
639
640/// Write the SSR `state` into the bootstrap block according to the protocol's
641/// build-time selection and escape it for safe embedding in a `<script>`.
642///
643/// [`ProjectedState`] serializes only the allowlisted keys, so for the typical
644/// payload — a large state with a small hydratable surface — serde ever only
645/// touches the projected subset. Serialization reuses the proven
646/// [`write_script_safe_json`] path (serde's fast `Vec<u8>` target plus a single
647/// SIMD-accelerated `</` escape pass), which matches the pre-projection cost
648/// when every key is hydratable and collapses to a few bytes when it is not.
649/// Buffering the projected bytes and escaping once is measurably faster than
650/// streaming through a per-token `io::Write` adapter, and the projected buffer
651/// is tiny in the common case.
652/// Serialize a state object with the reserved [`STATE_INJECT_KEY`] entry
653/// omitted.
654///
655/// The reserved key carries host-supplied boundary HTML, not application
656/// state, so it must never reach the client hydration payload — shipping it
657/// would both duplicate the markup as a JSON string and expose the host's
658/// inject channel to client code. Filtering happens during serialization so
659/// the state tree is never cloned.
660struct StateWithoutReservedKey<'a> {
661    value: &'a Value,
662}
663
664impl Serialize for StateWithoutReservedKey<'_> {
665    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
666    where
667        S: serde::Serializer,
668    {
669        let Value::Object(map) = self.value else {
670            return self.value.serialize(serializer);
671        };
672        let mut out = serializer.serialize_map(Some(map.len().saturating_sub(1)))?;
673        for (key, value) in map {
674            if key == STATE_INJECT_KEY {
675                continue;
676            }
677            out.serialize_entry(key, value)?;
678        }
679        out.end()
680    }
681}
682
683/// Write the complete state, stripping the reserved inject key when present.
684///
685/// The membership test is a single map lookup and the common case — no
686/// reserved key — takes the original zero-overhead path unchanged.
687fn write_full_state(
688    writer: &mut dyn ResponseWriter,
689    scratch: &mut Vec<u8>,
690    state: &Value,
691) -> Result<()> {
692    if matches!(state, Value::Object(map) if map.contains_key(STATE_INJECT_KEY)) {
693        return write_script_safe_json(writer, scratch, &StateWithoutReservedKey { value: state });
694    }
695    write_script_safe_json(writer, scratch, state)
696}
697
698pub(crate) fn write_selected_state(
699    writer: &mut dyn ResponseWriter,
700    scratch: &mut Vec<u8>,
701    state: &Value,
702    selection: &StateSelection<'_>,
703) -> Result<()> {
704    let keys = match selection {
705        StateSelection::Full => return write_full_state(writer, scratch, state),
706        StateSelection::Keys(keys) => keys.as_slice(),
707        StateSelection::BorrowedKeys(keys) => *keys,
708    };
709    if keys.is_empty() {
710        return writer.write("{}");
711    }
712
713    // Projection membership may use binary search, so a mis-sorted key set
714    // would silently drop hydration keys. The key allowlist is produced sorted +
715    // deduped at build time; this guard makes hand-built protocols that violate
716    // the invariant fail loudly in tests at zero release cost.
717    debug_assert!(
718        keys.windows(2).all(|pair| pair[0] <= pair[1]),
719        "hydration keys must be sorted for binary-search projection"
720    );
721    if let Value::Object(map) = state {
722        let selects_entire_map =
723            keys.len() == map.len() && keys.iter().copied().eq(map.keys().map(String::as_str));
724        if selects_entire_map {
725            return write_full_state(writer, scratch, state);
726        }
727    }
728    write_script_safe_json(writer, scratch, &ProjectedState { value: state, keys })
729}
730
731// Covers the common route surface without trusting protocol-derived counts for
732// an eager allocation; larger key sets grow only as actual keys are visited.
733pub(crate) const INITIAL_KEY_CAPACITY: usize = 16;
734
735/// Request-scoped state selection derived from reachable component metadata.
736pub(crate) enum StateSelection<'a> {
737    /// Preserve the complete state value.
738    Full,
739    /// Project an object to a sorted, deduplicated key allowlist.
740    Keys(Vec<&'a str>),
741    /// Project using request-local scratch owned by the streaming render.
742    BorrowedKeys(&'a [&'a str]),
743}
744
745#[derive(Clone, Copy)]
746enum ComponentStateSurface {
747    Hydration,
748    Navigation,
749}
750
751/// Select initial state for the components reachable on this request path.
752///
753/// Non-WebUI protocols preserve full state without walking component surfaces.
754/// WebUI protocols project exact surfaces, while any unknown surface restores
755/// the full state for correctness.
756pub(crate) fn collect_hydration_state<'a, 'b>(
757    protocol: &'a WebUIProtocol,
758    components: impl IntoIterator<Item = &'b str>,
759) -> StateSelection<'a> {
760    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
761        return StateSelection::Full;
762    }
763    collect_component_state(protocol, components, ComponentStateSurface::Hydration)
764}
765
766pub(crate) fn collect_hydration_state_into<'a, 'b>(
767    protocol: &'a WebUIProtocol,
768    components: impl IntoIterator<Item = &'b str>,
769    keys: &mut Vec<&'a str>,
770) -> bool {
771    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
772        keys.clear();
773        return true;
774    }
775    collect_component_state_into(protocol, components, ComponentStateSurface::Hydration, keys)
776}
777
778/// Select state for client-created components reachable during navigation.
779pub(crate) fn collect_navigation_state<'a, 'b>(
780    protocol: &'a WebUIProtocol,
781    components: impl IntoIterator<Item = &'b str>,
782) -> StateSelection<'a> {
783    collect_component_state(protocol, components, ComponentStateSurface::Navigation)
784}
785
786fn collect_component_state<'a, 'b>(
787    protocol: &'a WebUIProtocol,
788    components: impl IntoIterator<Item = &'b str>,
789    surface: ComponentStateSurface,
790) -> StateSelection<'a> {
791    let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
792    if collect_component_state_into(protocol, components, surface, &mut keys) {
793        StateSelection::Full
794    } else {
795        StateSelection::Keys(keys)
796    }
797}
798
799/// Fill a reusable state-key allowlist. Returns `true` when correctness
800/// requires sending full state instead of the collected keys.
801fn collect_component_state_into<'a, 'b>(
802    protocol: &'a WebUIProtocol,
803    components: impl IntoIterator<Item = &'b str>,
804    surface: ComponentStateSurface,
805    keys: &mut Vec<&'a str>,
806) -> bool {
807    keys.clear();
808    for name in components {
809        let Some(component) = protocol.components.get(name) else {
810            return true;
811        };
812        let (mode, component_keys) = match surface {
813            ComponentStateSurface::Hydration => {
814                (component.hydration_mode, &component.hydration_keys)
815            }
816            ComponentStateSurface::Navigation => (
817                match component.navigation_mode {
818                    Some(mode) => mode,
819                    None if !component.navigation_keys.is_empty() => {
820                        StateProjectionMode::Keys as i32
821                    }
822                    None => return true,
823                },
824                &component.navigation_keys,
825            ),
826        };
827        if mode == StateProjectionMode::All as i32 {
828            return true;
829        }
830        if mode == StateProjectionMode::Keys as i32
831            || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
832        {
833            keys.extend(component_keys.iter().map(String::as_str));
834        } else if mode != StateProjectionMode::None as i32 {
835            return true;
836        }
837    }
838    keys.sort_unstable();
839    keys.dedup();
840    false
841}
842
843pub(crate) fn write_webui_bootstrap(
844    writer: &mut dyn ResponseWriter,
845    scratch: &mut Vec<u8>,
846    bootstrap: WebUiBootstrap<'_>,
847) -> Result<()> {
848    let mut wrote_field = false;
849
850    writer.write("{")?;
851    if !bootstrap.chain.is_empty() {
852        write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?;
853    }
854    if !bootstrap.css_hrefs.is_empty() {
855        write_json_field(
856            writer,
857            scratch,
858            &mut wrote_field,
859            "css",
860            bootstrap.css_hrefs,
861        )?;
862    }
863    write_json_field(
864        writer,
865        scratch,
866        &mut wrote_field,
867        "inventory",
868        bootstrap.inventory,
869    )?;
870    if let Some(nonce) = bootstrap.nonce {
871        write_json_field(writer, scratch, &mut wrote_field, "nonce", nonce)?;
872    }
873    write_json_field_name(writer, &mut wrote_field, "state")?;
874    write_selected_state(writer, scratch, bootstrap.state, &bootstrap.state_selection)?;
875    if !bootstrap.style_specs.is_empty() {
876        write_json_field(
877            writer,
878            scratch,
879            &mut wrote_field,
880            "styles",
881            bootstrap.style_specs,
882        )?;
883    }
884    if bootstrap
885        .templates
886        .iter()
887        .any(|template| !template.template_json.is_empty())
888    {
889        write_json_field_name(writer, &mut wrote_field, "templates")?;
890        write_webui_template_json_map(writer, scratch, bootstrap.templates)?;
891    }
892    writer.write("}")
893}
894
895fn write_webui_data_block(
896    writer: &mut dyn ResponseWriter,
897    scratch: &mut Vec<u8>,
898    bootstrap: WebUiBootstrap<'_>,
899) -> Result<()> {
900    writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
901    if let Some(nonce) = bootstrap.nonce {
902        writer.write(" nonce=\"")?;
903        writer.write(nonce)?;
904        writer.write("\"")?;
905    }
906    writer.write(">")?;
907    write_webui_bootstrap(writer, scratch, bootstrap)?;
908    writer.write("</script>\n")
909}
910
911fn write_webui_template_json_map(
912    writer: &mut dyn ResponseWriter,
913    scratch: &mut Vec<u8>,
914    templates: &[WebUiTemplatePayload<'_>],
915) -> Result<()> {
916    writer.write("{")?;
917    let mut wrote = false;
918    for template in templates {
919        if template.template_json.is_empty() {
920            continue;
921        }
922        if wrote {
923            writer.write(",")?;
924        }
925        wrote = true;
926        write_script_safe_json(writer, scratch, template.tag_name)?;
927        writer.write(":")?;
928        write_script_safe_json_str(writer, template.template_json)?;
929    }
930    writer.write("}")
931}
932
933fn resolve_value_from_sources<'ctx, 'state>(
934    path: &str,
935    local_vars: &'ctx HashMap<String, Value>,
936    state: &'state Value,
937) -> Option<Cow<'ctx, Value>>
938where
939    'state: 'ctx,
940{
941    if let Some(first_part) = path.split('.').next() {
942        if let Some(local_value) = local_vars.get(first_part) {
943            if first_part.len() == path.len() {
944                return Some(Cow::Borrowed(local_value));
945            }
946            let remaining = &path[first_part.len() + 1..];
947            if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
948                return Some(value);
949            }
950        }
951    }
952
953    find_value_by_dotted_path_ref(path, state)
954}
955
956impl WebUIHandler {
957    /// Create a new WebUI handler with no plugin.
958    pub fn new() -> Self {
959        Self {
960            plugin_factory: None,
961        }
962    }
963
964    /// Create a new WebUI handler with a plugin factory.
965    ///
966    /// Each render call creates a fresh plugin instance from the factory,
967    /// enabling concurrent renders with `&self`.
968    pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
969        Self {
970            plugin_factory: Some(factory),
971        }
972    }
973
974    #[cfg(test)]
975    fn handle(
976        &self,
977        document: &WebUIProtocol,
978        state: &Value,
979        options: &RenderOptions<'_>,
980        writer: &mut dyn ResponseWriter,
981    ) -> Result<()> {
982        let protocol = Protocol::new(document.clone());
983        self.render(&protocol, state, options, writer)
984    }
985
986    /// Process a fragment by its ID.
987    ///
988    /// The `context` parameter contains scope-local variables that are accessible during rendering,
989    /// such as loop iteration variables. This is separate from the global `state`.
990    fn process_fragment_id<'data>(
991        &self,
992        fragment_id: &str,
993        context: &mut WebUIProcessContext<'data, '_, '_>,
994    ) -> Result<()> {
995        if let Some(fragment_list) = context.protocol.fragments.get(fragment_id) {
996            self.process_fragment(&fragment_list.fragments, context)
997        } else {
998            Err(HandlerError::MissingFragment(fragment_id.to_string()))
999        }
1000    }
1001
1002    /// Process a vector of fragments.
1003    ///
1004    /// The `context` maintains scope-specific variables that can be accessed by fragments
1005    /// during rendering, while `state` contains the global application state.
1006    fn process_fragment<'data>(
1007        &self,
1008        fragments: &'data [WebUIFragment],
1009        context: &mut WebUIProcessContext<'data, '_, '_>,
1010    ) -> Result<()> {
1011        // Pre-scan: find the best matching route among sibling routes by specificity.
1012        // This ensures `/contacts/add` (2 literals) beats `/contacts/:id` (1 literal).
1013        // Resolves relative paths (`./`) using the current route_base.
1014        let best_route = route_renderer::find_best_route_match(
1015            fragments,
1016            context.request_path,
1017            &context.route_base,
1018            context.route_index,
1019        );
1020        self.process_fragment_range(fragments, 0..fragments.len(), &best_route, context)
1021    }
1022
1023    fn process_fragment_range<'data>(
1024        &self,
1025        fragments: &'data [WebUIFragment],
1026        range: std::ops::Range<usize>,
1027        best_route: &Option<(String, route_matcher::RouteMatch)>,
1028        context: &mut WebUIProcessContext<'data, '_, '_>,
1029    ) -> Result<()> {
1030        let Some(selected) = fragments.get(range.clone()) else {
1031            return Err(invalid_fragment_range_error(&range, fragments.len()));
1032        };
1033        for (offset, item) in selected.iter().enumerate() {
1034            let index = range.start + offset;
1035            if context.streaming.is_some() {
1036                validate_pending_streaming_root(item, context)?;
1037                validate_streaming_root_opening(&fragments[..index], item)?;
1038            }
1039            match item.fragment.as_ref() {
1040                Some(Fragment::Raw(raw)) => {
1041                    context.writer.write(&raw.value)?;
1042                }
1043                Some(Fragment::Component(component)) => {
1044                    self.process_component(
1045                        component,
1046                        ComponentHostOrigin::ParserProduced,
1047                        context,
1048                    )?;
1049                }
1050                Some(Fragment::ForLoop(for_loop)) => {
1051                    self.process_for_loop(for_loop, context)?;
1052                }
1053                Some(Fragment::Signal(signal)) => {
1054                    self.process_signal(signal, context)?;
1055                }
1056                Some(Fragment::IfCond(if_cond)) => {
1057                    self.process_if(if_cond, context)?;
1058                }
1059                Some(Fragment::Attribute(attr)) => {
1060                    self.process_attribute(attr, context)?;
1061                }
1062                Some(Fragment::Plugin(plugin_frag)) => {
1063                    if let Some(p) = &mut context.plugin {
1064                        p.on_element_data(&plugin_frag.data, context.writer)?;
1065                    }
1066                }
1067                Some(Fragment::Route(route_frag)) => {
1068                    self.process_route(route_frag, best_route, context)?;
1069                }
1070                Some(Fragment::Outlet(_)) => {
1071                    self.process_outlet(context)?;
1072                }
1073                None => {}
1074            }
1075        }
1076        ensure_no_pending_streaming_root(context, "the end of the containing fragment")
1077    }
1078
1079    /// Process an `<outlet />` directive.
1080    ///
1081    /// Matches children from the currently active route's `children` field
1082    /// against the request path, renders the matched child `<webui-route>`
1083    /// elements directly at this position (no wrapper element).
1084    fn process_outlet(&self, context: &mut WebUIProcessContext) -> Result<()> {
1085        let mut children = std::mem::take(&mut context.route_children);
1086        if children.is_empty() {
1087            return Ok(());
1088        }
1089
1090        // Find the best matching child route
1091        let request_segments = route_matcher::split_request_path(context.request_path);
1092        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
1093        for (idx, child) in children.iter().enumerate() {
1094            if let Some(m) = route_matcher::match_route_indexed_with_segments(
1095                context.route_index,
1096                &child.path,
1097                &context.route_base,
1098                &request_segments,
1099                child.exact,
1100            ) {
1101                let is_better = best
1102                    .as_ref()
1103                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
1104                if is_better {
1105                    best = Some((idx, m));
1106                }
1107            }
1108        }
1109
1110        // Extract grandchildren from the matched child to avoid cloning.
1111        // We swap out the children vec so we can move it into context without
1112        // cloning, then swap an empty vec back for the sibling rendering pass.
1113        let grandchildren = if let Some((idx, _)) = &best {
1114            std::mem::take(&mut children[*idx].children)
1115        } else {
1116            Vec::new()
1117        };
1118
1119        if let Some((idx, ref rm)) = best {
1120            let matched_child = &children[idx];
1121            let comp = &matched_child.fragment_id;
1122
1123            if !comp.is_empty() {
1124                let saved_route_base = context.route_base.clone();
1125                let saved_route_children = std::mem::take(&mut context.route_children);
1126
1127                if rm.consumed_segments > 0 {
1128                    context.route_base = Cow::Owned(route_matcher::compute_route_base(
1129                        context.request_path,
1130                        rm.consumed_segments,
1131                    ));
1132                }
1133
1134                context.route_children = grandchildren;
1135
1136                // Emit matched <webui-route>
1137                context.writer.write("<webui-route")?;
1138                if !matched_child.path.is_empty() {
1139                    context.writer.write(" path=\"")?;
1140                    context.writer.write(&matched_child.path)?;
1141                    context.writer.write("\"")?;
1142                }
1143                context.writer.write(" component=\"")?;
1144                context.writer.write(comp)?;
1145                context.writer.write("\"")?;
1146                if matched_child.exact {
1147                    context.writer.write(" exact")?;
1148                }
1149                route_renderer::write_route_pending_attrs(context.writer, matched_child)?;
1150                // Emit data-ri for O(1) client-side element binding
1151                let ri = context.route_chain_index;
1152                context.route_chain_index += 1;
1153                context.writer.write(" data-ri=\"")?;
1154                write_usize(context.writer, ri)?;
1155                context.writer.write("\" active>")?;
1156
1157                context.writer.write("<")?;
1158                context.writer.write(comp)?;
1159                if let Some(p) = &context.plugin {
1160                    p.write_route_component_state(context.state, context.writer)?;
1161                }
1162                prepare_generated_streaming_root(comp, context)?;
1163                context.writer.write(">")?;
1164
1165                self.process_component(
1166                    &webui_protocol::WebUIFragmentComponent {
1167                        fragment_id: comp.clone(),
1168                    },
1169                    ComponentHostOrigin::HandlerGenerated,
1170                    context,
1171                )?;
1172
1173                context.writer.write("</")?;
1174                context.writer.write(comp)?;
1175                context.writer.write(">")?;
1176                context.writer.write("</webui-route>")?;
1177
1178                context.route_base = saved_route_base;
1179                context.route_children = saved_route_children;
1180            }
1181        }
1182
1183        // Render non-matched siblings as hidden
1184        for (idx, child) in children.iter().enumerate() {
1185            let is_matched = best.as_ref().is_some_and(|(bi, _)| *bi == idx);
1186            if !is_matched && !child.fragment_id.is_empty() {
1187                context.writer.write("<webui-route")?;
1188                if !child.path.is_empty() {
1189                    context.writer.write(" path=\"")?;
1190                    context.writer.write(&child.path)?;
1191                    context.writer.write("\"")?;
1192                }
1193                context.writer.write(" component=\"")?;
1194                context.writer.write(&child.fragment_id)?;
1195                context.writer.write("\"")?;
1196                if child.exact {
1197                    context.writer.write(" exact")?;
1198                }
1199                route_renderer::write_route_pending_attrs(context.writer, child)?;
1200                context
1201                    .writer
1202                    .write(" style=\"display:none\"></webui-route>")?;
1203            }
1204        }
1205
1206        Ok(())
1207    }
1208
1209    /// Emit a `<script type="importmap">` tag that registers a component's
1210    /// CSS module under its specifier via a `data:text/css,…` URI.
1211    ///
1212    /// Requires Multiple Import Maps (Chrome 133+); each call emits an
1213    /// independent importmap that the browser merges at the document
1214    /// level. The per-render CSP nonce is applied when set (importmap
1215    /// scripts honor `script-src`).
1216    ///
1217    /// Example for `my-comp` with CSS `span{color:blue;}`:
1218    /// `<script type="importmap" nonce="...">{"imports":{"my-comp":"data:text/css,span{color:blue;}"}}</script>`
1219    fn emit_css_module_importmap(
1220        &self,
1221        specifier: &str,
1222        css: &str,
1223        context: &mut WebUIProcessContext,
1224    ) -> Result<()> {
1225        let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
1226        context.writer.write(&tag)?;
1227        Ok(())
1228    }
1229
1230    /// Emit a component's CSS module importmap on its first render
1231    /// (deduped by `rendered_components`) into the component's light DOM,
1232    /// so the browser registers it under the component's specifier
1233    /// before the shadow root template is parsed. See
1234    /// [`Self::emit_css_module_importmap`] for the emitted shape.
1235    ///
1236    /// Only components rendered on the current route get inline
1237    /// definitions; others receive theirs via `templateStyles` during
1238    /// SPA partial navigation.
1239    fn emit_css_module(
1240        &self,
1241        component: &webui_protocol::WebUIFragmentComponent,
1242        context: &mut WebUIProcessContext,
1243    ) -> Result<()> {
1244        let metadata_already_streamed = context.streaming.as_ref().is_some_and(|streaming| {
1245            streaming_template_already_sent(
1246                streaming,
1247                context.component_index,
1248                &component.fragment_id,
1249            )
1250        });
1251        if !metadata_already_streamed
1252            && !context.rendered_components.contains(&component.fragment_id)
1253        {
1254            if let Some(css) = context
1255                .protocol
1256                .components
1257                .get(&component.fragment_id)
1258                .map(|c| c.css.as_str())
1259                .filter(|s| !s.is_empty())
1260            {
1261                self.emit_css_module_importmap(&component.fragment_id, css, context)?;
1262            }
1263        }
1264        Ok(())
1265    }
1266
1267    /// Process a route fragment — renders `<webui-route>` with matched/hidden state.
1268    fn process_route(
1269        &self,
1270        route_frag: &webui_protocol::WebUiFragmentRoute,
1271        best_route: &Option<(String, route_matcher::RouteMatch)>,
1272        context: &mut WebUIProcessContext,
1273    ) -> Result<()> {
1274        let is_matched = best_route
1275            .as_ref()
1276            .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
1277
1278        context.writer.write("<webui-route")?;
1279        if !route_frag.path.is_empty() {
1280            context.writer.write(" path=\"")?;
1281            context.writer.write(&route_frag.path)?;
1282            context.writer.write("\"")?;
1283        }
1284        if !route_frag.fragment_id.is_empty() {
1285            context.writer.write(" component=\"")?;
1286            context.writer.write(&route_frag.fragment_id)?;
1287            context.writer.write("\"")?;
1288        }
1289        if route_frag.exact {
1290            context.writer.write(" exact")?;
1291        }
1292        route_renderer::write_route_pending_attrs(context.writer, route_frag)?;
1293
1294        if is_matched {
1295            // Emit data-ri for O(1) client-side element binding
1296            let ri = context.route_chain_index;
1297            context.route_chain_index += 1;
1298            context.writer.write(" data-ri=\"")?;
1299            write_usize(context.writer, ri)?;
1300            context.writer.write("\" active>")?;
1301
1302            if !route_frag.fragment_id.is_empty() {
1303                let saved_route_base = context.route_base.clone();
1304                let saved_route_children = std::mem::take(&mut context.route_children);
1305                if let Some((_, ref rm)) = best_route {
1306                    context.route_base = Cow::Owned(route_matcher::compute_route_base(
1307                        context.request_path,
1308                        rm.consumed_segments,
1309                    ));
1310                }
1311
1312                context.route_children = route_frag.children.clone();
1313
1314                let comp = webui_protocol::WebUIFragmentComponent {
1315                    fragment_id: route_frag.fragment_id.clone(),
1316                };
1317
1318                context.writer.write("<")?;
1319                context.writer.write(&route_frag.fragment_id)?;
1320                if let Some(p) = &context.plugin {
1321                    p.write_route_component_state(context.state, context.writer)?;
1322                }
1323                prepare_generated_streaming_root(&route_frag.fragment_id, context)?;
1324                context.writer.write(">")?;
1325
1326                self.process_component(&comp, ComponentHostOrigin::HandlerGenerated, context)?;
1327
1328                context.writer.write("</")?;
1329                context.writer.write(&route_frag.fragment_id)?;
1330                context.writer.write(">")?;
1331
1332                context.route_base = saved_route_base;
1333                context.route_children = saved_route_children;
1334            }
1335        } else {
1336            context.writer.write(" style=\"display:none\">")?;
1337        }
1338
1339        context.writer.write("</webui-route>")?;
1340        Ok(())
1341    }
1342
1343    /// Process a component fragment.
1344    fn process_component(
1345        &self,
1346        component: &webui_protocol::WebUIFragmentComponent,
1347        origin: ComponentHostOrigin,
1348        context: &mut WebUIProcessContext,
1349    ) -> Result<()> {
1350        if context.streaming.is_some() {
1351            consume_streaming_component_root(&component.fragment_id, origin, context)?;
1352            // Capture only after root parity succeeds, so malformed protocols
1353            // cannot contribute unmarked hosts to a checkpoint.
1354            record_checkpoint_tag(context, &component.fragment_id);
1355        }
1356
1357        // Emit the component's CSS module importmap into its light DOM and track
1358        // the component as rendered on first encounter only. `rendered_components`
1359        // is a set, so gating the `insert` (and its `String` clone) behind the
1360        // first-encounter check avoids allocating a throwaway `String` for every
1361        // duplicate instance while keeping the set contents identical.
1362        if !context.rendered_components.contains(&component.fragment_id) {
1363            self.emit_css_module(component, context)?;
1364            context
1365                .rendered_components
1366                .insert(component.fragment_id.clone());
1367        }
1368
1369        // Save parent scope. `mem::take` leaves an alloc-free empty map behind.
1370        let saved_local_vars = std::mem::take(&mut context.local_vars);
1371        // The component's accumulated attrs become its local vars; the next
1372        // sibling accumulates into a recycled (capacity-preserving) map from the
1373        // request-local pool instead of a freshly allocated `HashMap`.
1374        let saved_component_attrs = std::mem::replace(
1375            &mut context.component_attrs,
1376            take_scope_map(&mut context.scope_pool),
1377        );
1378        context.local_vars = saved_component_attrs;
1379
1380        if let Some(p) = &mut context.plugin {
1381            p.push_scope();
1382        }
1383
1384        self.process_fragment_id(&component.fragment_id, context)?;
1385
1386        if let Some(p) = &mut context.plugin {
1387            p.pop_scope();
1388        }
1389
1390        // Restore parent scope, recycling this component's local map (its
1391        // accumulated attrs) back into the pool so a sibling reuses its capacity.
1392        let used_locals = std::mem::replace(&mut context.local_vars, saved_local_vars);
1393        recycle_scope_map(&mut context.scope_pool, used_locals);
1394        // The attr accumulator (pulled from the pool above) is cleared for the
1395        // next sibling while retaining its bucket capacity.
1396        context.component_attrs.clear();
1397
1398        Ok(())
1399    }
1400
1401    /// Resolve a dotted path value, checking local variables first, then global state.
1402    fn resolve_value(
1403        &self,
1404        path: &str,
1405        context: &WebUIProcessContext<'_, '_, '_>,
1406    ) -> Option<Value> {
1407        resolve_value_from_sources(path, &context.local_vars, context.state).map(Cow::into_owned)
1408    }
1409
1410    /// Evaluate a condition expression against the current context.
1411    ///
1412    /// Uses a resolver closure that checks local variables first, then falls
1413    /// back to global state — avoiding a full clone of the state tree.
1414    /// Returns false if the condition references a missing value.
1415    fn evaluate_condition(
1416        &self,
1417        condition: &webui_protocol::ConditionExpr,
1418        context: &WebUIProcessContext,
1419    ) -> Result<bool> {
1420        let local_vars = &context.local_vars;
1421        let state = context.state;
1422        match evaluate_with_resolver(condition, |path| {
1423            resolve_value_from_sources(path, local_vars, state)
1424        }) {
1425            Ok(result) => Ok(result),
1426            Err(ExpressionError::MissingValue(_)) => Ok(false),
1427            Err(e) => Err(HandlerError::Evaluation(e.to_string())),
1428        }
1429    }
1430
1431    /// Process a for loop fragment.
1432    ///
1433    /// Creates a new context for each iteration that includes the current loop item.
1434    /// This allows nested templates to access both the loop variable and any parent context.
1435    /// Example: `for item in items` makes "item" available in the loop body.
1436    fn process_for_loop(
1437        &self,
1438        for_loop: &webui_protocol::WebUIFragmentFor,
1439        context: &mut WebUIProcessContext,
1440    ) -> Result<()> {
1441        let collection_name = &for_loop.collection;
1442
1443        // If the collection is missing, treat it as empty (0 iterations) — matches NodeJS behavior.
1444        // Hydration comments are always emitted regardless of collection presence.
1445        let items = match self.resolve_value(collection_name, context) {
1446            Some(Value::Array(arr)) => arr,
1447            Some(_) => {
1448                return Err(HandlerError::TypeError(format!(
1449                    "Collection '{}' is not an array",
1450                    collection_name
1451                )))
1452            }
1453            None => Vec::new(),
1454        };
1455
1456        if let Some(p) = &mut context.plugin {
1457            p.on_for_start(&for_loop.fragment_id, context.writer)?;
1458        }
1459
1460        // Hot-loop optimisation: the loop variable name is `String`-keyed
1461        // in `local_vars`. The naive impl re-inserts (and so re-allocates
1462        // the key) on every iteration — a 1000-item loop pays 2000 String
1463        // clones for the key alone. Instead, we save the outer-scope
1464        // value (if any) ONCE before the loop, install the key ONCE with
1465        // an empty placeholder, then overwrite the value in-place each
1466        // iteration via `get_mut`. Restoration at the end happens once.
1467        let item_name = for_loop.item.as_str();
1468        let saved_value = context.local_vars.remove(item_name);
1469        // Pre-insert the key so per-iteration `get_mut` is infallible.
1470        // Cost: at most one `String::from(item_name)` for the lifetime
1471        // of the loop, regardless of iteration count.
1472        if !items.is_empty() {
1473            context
1474                .local_vars
1475                .insert(item_name.to_string(), Value::Null);
1476        }
1477        for (i, item) in items.into_iter().enumerate() {
1478            if let Some(p) = &mut context.plugin {
1479                p.on_repeat_item_start(i, context.writer)?;
1480                p.push_scope();
1481            }
1482
1483            // O(1) value swap; no key allocation.
1484            if let Some(slot) = context.local_vars.get_mut(item_name) {
1485                *slot = item;
1486            }
1487            self.process_fragment_id(&for_loop.fragment_id, context)?;
1488
1489            if let Some(p) = &mut context.plugin {
1490                p.pop_scope();
1491                p.on_repeat_item_end(i, context.writer)?;
1492            }
1493        }
1494        // Restore outer scope (or remove the placeholder we installed).
1495        match saved_value {
1496            Some(value) => {
1497                context.local_vars.insert(item_name.to_string(), value);
1498            }
1499            None => {
1500                context.local_vars.remove(item_name);
1501            }
1502        }
1503
1504        if let Some(p) = &mut context.plugin {
1505            p.on_for_end(&for_loop.fragment_id, context.writer)?;
1506        }
1507
1508        Ok(())
1509    }
1510
1511    /// Process a signal fragment.
1512    ///
1513    /// Looks up the value in the context first (for local variables), then in the global state.
1514    /// This prioritization allows local variables (like loop items) to override global state.
1515    /// If the value is not found in either scope, an empty string is returned.
1516    #[inline]
1517    fn process_signal<'data>(
1518        &self,
1519        signal: &'data webui_protocol::WebUIFragmentSignal,
1520        context: &mut WebUIProcessContext<'data, '_, '_>,
1521    ) -> Result<()> {
1522        if signal.raw {
1523            self.process_raw_signal(signal, context)
1524        } else {
1525            self.process_state_signal(signal, context)
1526        }
1527    }
1528
1529    #[inline(never)]
1530    fn process_raw_signal<'data>(
1531        &self,
1532        signal: &'data webui_protocol::WebUIFragmentSignal,
1533        context: &mut WebUIProcessContext<'data, '_, '_>,
1534    ) -> Result<()> {
1535        let Some(structural_value) = structural_signal_value(signal) else {
1536            return self.process_state_signal(signal, context);
1537        };
1538
1539        if context.streaming.is_some()
1540            && self.process_streaming_signal(structural_value, context)?
1541        {
1542            return Ok(());
1543        }
1544
1545        // Hook: emit nonce meta and CSS <link> tags before </head>.
1546        // Guarded by `head_end_emitted` so a malformed protocol cannot
1547        // emit nonce/preloads/inject more than once per render.
1548        if structural_value == "head_end" && !context.head_end_emitted {
1549            context.head_end_emitted = true;
1550            if let Some(nonce) = context.nonce {
1551                context
1552                    .writer
1553                    .write("<meta name=\"webui-nonce\" content=\"")?;
1554                context
1555                    .writer
1556                    .write(&crate::html_encode::encode_safe(nonce))?;
1557                context.writer.write("\">")?;
1558            }
1559
1560            if !context.protocol.component_render_css.is_empty() {
1561                context.writer.write("<style data-webui-render-policy")?;
1562                if let Some(nonce) = context.nonce {
1563                    context.writer.write(" nonce=\"")?;
1564                    context
1565                        .writer
1566                        .write(&crate::html_encode::encode_safe(nonce))?;
1567                    context.writer.write("\"")?;
1568                }
1569                context.writer.write(">")?;
1570                context
1571                    .writer
1572                    .write(&context.protocol.component_render_css)?;
1573                context.writer.write("</style>")?;
1574            }
1575
1576            // Emit CSS <link> tags in <head> for Link-strategy components.
1577            // For components with a non-empty css_href:
1578            //   Link + Shadow → <link rel="preload"> (stylesheet is in shadow root)
1579            //   Link + Light  → <link rel="stylesheet"> (no shadow root)
1580            //
1581            // Style and Module strategies emit their CSS during component
1582            // rendering (shadow-DOM template / importmap respectively).
1583            let is_link = context.protocol.css_strategy() == webui_protocol::CssStrategy::Link;
1584            let is_shadow = context.protocol.dom_strategy() == webui_protocol::DomStrategy::Shadow;
1585
1586            if is_link {
1587                let (needed_components, _) =
1588                    crate::route_handler::get_needed_components_for_request(
1589                        context.protocol,
1590                        context.entry_id,
1591                        context.request_path,
1592                        "",
1593                        (context.component_index, context.route_index),
1594                    )?;
1595
1596                for name in &needed_components {
1597                    if let Some(href) = context
1598                        .protocol
1599                        .components
1600                        .get(name)
1601                        .map(|c| c.css_href.as_str())
1602                        .filter(|h| !h.is_empty())
1603                    {
1604                        if is_shadow {
1605                            context.writer.write("<link rel=\"preload\" href=\"")?;
1606                            context.writer.write(href)?;
1607                            context
1608                                .writer
1609                                .write("\" as=\"style\" data-webui-ssr-preload=\"style\">")?;
1610                        } else {
1611                            context.writer.write("<link rel=\"stylesheet\" href=\"")?;
1612                            context.writer.write(href)?;
1613                            context.writer.write("\">")?;
1614                        }
1615                    }
1616                }
1617            }
1618
1619            // Compiler-resolved `modulepreload` hints for the shared chunks
1620            // the page's module entries statically import. Those chunks are
1621            // named only inside the entry's own bytes, so without this the
1622            // browser must download and parse the entry before it can even
1623            // discover them.
1624            //
1625            // Emitted after the CSS links on purpose: CSS is render-blocking
1626            // and owns first paint, while these own first interaction, so the
1627            // stylesheet requests go out first. The list arrives pre-ordered
1628            // (largest chunk first) and pre-resolved from the build, so this
1629            // is a straight write with no per-request work.
1630            if !context.protocol.module_preloads.is_empty() {
1631                for href in &context.protocol.module_preloads {
1632                    context
1633                        .writer
1634                        .write("<link rel=\"modulepreload\" href=\"")?;
1635                    context.writer.write(href)?;
1636                    context.writer.write("\">")?;
1637                }
1638            }
1639
1640            // Per-render `head_inject` HTML — image preloads, A/B test
1641            // markers, etc. supplied by the host via RenderOptions.
1642            // Emitted at the structural head_end boundary, after the
1643            // built-in nonce + CSS-link emissions, so host injects
1644            // appear immediately before `</head>`.
1645            if let Some(html) = context.head_inject {
1646                context.writer.write(html)?;
1647            }
1648
1649            // Reserved-state `headEnd` HTML, last at this boundary so a
1650            // host that sets both channels gets a deterministic order:
1651            // built-in emissions, then `RenderOptions`, then state.
1652            if let Some(html) = context.state_inject.head_end {
1653                context.writer.write(html)?;
1654            }
1655        }
1656
1657        // Hook: emit state-supplied HTML immediately after `<body>`.
1658        // Guarded by its own dedup flag so a malformed protocol emitting
1659        // the signal twice cannot duplicate the injected markup.
1660        if structural_value == "body_start" && !context.body_start_emitted {
1661            context.body_start_emitted = true;
1662            if let Some(html) = context.state_inject.body_start {
1663                context.writer.write(html)?;
1664            }
1665        }
1666
1667        // Hook: emit component templates and host body_inject before </body>.
1668        // Single guarded block so the dedup flag protects both the
1669        // hydration emission and the host inject from a malformed
1670        // protocol that fires `body_end` more than once per render.
1671        if structural_value == "body_end" && !context.body_end_emitted {
1672            context.body_end_emitted = true;
1673            if context.plugin.is_some() {
1674                // Emit templates for all REACHABLE components on the current route,
1675                // not just those rendered in this SSR pass. Components inside false
1676                // <if> blocks or empty <for> loops are reachable via client-side
1677                // state changes and need their templates available without a server
1678                // round-trip. The graph walker follows conditional and loop branches
1679                // unconditionally, but only descends into the matched route chain —
1680                // components on other routes are delivered via SPA partial navigation.
1681                let reachable = crate::route_handler::collect_reachable_components_for_request(
1682                    context.protocol,
1683                    context.entry_id,
1684                    context.request_path,
1685                    context.route_index,
1686                );
1687                let state_selection =
1688                    collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
1689
1690                // Emit CSS module importmaps for reachable-but-unrendered
1691                // components so the framework can adopt them when an `<if>`
1692                // condition flips true client-side.
1693                for name in &reachable {
1694                    if !context.rendered_components.contains(name) {
1695                        if let Some(css) = context
1696                            .protocol
1697                            .components
1698                            .get(name)
1699                            .map(|c| c.css.as_str())
1700                            .filter(|s| !s.is_empty())
1701                        {
1702                            self.emit_css_module_importmap(name, css, context)?;
1703                        }
1704                    }
1705                }
1706
1707                // Try to collect split WebUI template payloads. If the plugin
1708                // returns None (non-WebUI templates, e.g. FAST), fall back to
1709                // separate emission.
1710                let template_payloads = context
1711                    .plugin
1712                    .as_ref()
1713                    .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
1714
1715                if template_payloads.is_none() {
1716                    // Non-JS templates (FAST plugins) - emit separately
1717                    if let Some(ref p) = context.plugin {
1718                        p.emit_templates(
1719                            context.protocol,
1720                            &reachable,
1721                            context.nonce,
1722                            context.writer,
1723                        )?;
1724                    }
1725                }
1726
1727                // Compute the inventory hex from actually rendered components.
1728                let inventory_hex = crate::route_handler::encode_component_inventory(
1729                    &context.rendered_components,
1730                    context.component_index,
1731                );
1732
1733                // Chain
1734                let chain = crate::route_handler::collect_route_chain(
1735                    context.protocol,
1736                    context.entry_id,
1737                    context.request_path,
1738                    context.route_index,
1739                );
1740                let chain_json: Vec<Value> = chain
1741                    .iter()
1742                    .map(crate::route_handler::RouteChainEntry::to_json)
1743                    .collect();
1744
1745                // CSS hrefs emitted during SSR (Link-strategy components)
1746                let is_link = context.protocol.css_strategy() == webui_protocol::CssStrategy::Link;
1747                let mut css_hrefs: Vec<&str> = Vec::new();
1748                if is_link {
1749                    for name in &reachable {
1750                        if let Some(href) = context
1751                            .protocol
1752                            .components
1753                            .get(name)
1754                            .map(|c| c.css_href.as_str())
1755                            .filter(|h| !h.is_empty())
1756                        {
1757                            css_hrefs.push(href);
1758                        }
1759                    }
1760                }
1761
1762                // Module style specifiers emitted during SSR
1763                let mut style_specs: Vec<&str> = Vec::new();
1764                for name in &reachable {
1765                    if context
1766                        .protocol
1767                        .components
1768                        .get(name)
1769                        .map(|c| !c.css.is_empty())
1770                        .unwrap_or(false)
1771                    {
1772                        style_specs.push(name);
1773                    }
1774                }
1775
1776                let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
1777                let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
1778                write_webui_data_block(
1779                    context.writer,
1780                    &mut context.json_scratch,
1781                    WebUiBootstrap {
1782                        state: context.state,
1783                        state_selection,
1784                        chain: &chain_json,
1785                        inventory: &inventory_hex,
1786                        nonce: context.nonce,
1787                        css_hrefs: &css_hrefs,
1788                        style_specs: &style_specs,
1789                        templates: payloads,
1790                    },
1791                )?;
1792
1793                // Let the active plugin emit any framework-specific executable
1794                // side channel. FAST plugins default to no-op; WebUI installs
1795                // templateFns. Client packages parse #webui-data lazily.
1796                if let Some(ref plugin) = context.plugin {
1797                    plugin.emit_bootstrap_extension(
1798                        BootstrapExtensionContext {
1799                            protocol: context.protocol,
1800                            components: &reachable,
1801                            payloads,
1802                            nonce: context.nonce,
1803                        },
1804                        context.writer,
1805                    )?;
1806                }
1807            }
1808
1809            // Per-render `body_inject` HTML — dev livereload script,
1810            // analytics, etc. supplied by the host via RenderOptions.
1811            // Inside the dedup block but outside the plugin-only
1812            // sub-block above, so it fires regardless of whether a
1813            // hydration plugin is active. Appears immediately before
1814            // `</body>`.
1815            if let Some(html) = context.body_inject {
1816                context.writer.write(html)?;
1817            }
1818
1819            // Reserved-state `bodyEnd` HTML, last at this boundary. Same
1820            // precedence as `head_end`: built-ins, `RenderOptions`, state.
1821            if let Some(html) = context.state_inject.body_end {
1822                context.writer.write(html)?;
1823            }
1824        }
1825
1826        // Structural signals are never state lookups. In particular, ordinary
1827        // rendering ignores boundary/root markers byte-for-byte, while authored
1828        // raw bindings with the same visible key remain ordinary state.
1829        Ok(())
1830    }
1831
1832    #[inline]
1833    fn process_state_signal(
1834        &self,
1835        signal: &webui_protocol::WebUIFragmentSignal,
1836        context: &mut WebUIProcessContext,
1837    ) -> Result<()> {
1838        if let Some(p) = &mut context.plugin {
1839            p.on_binding_start(&signal.value, context.writer)?;
1840        }
1841
1842        if let Some(value) = self.resolve_value(&signal.value, context) {
1843            self.write_signal_value(&value, signal.raw, context.writer)?;
1844        }
1845
1846        if let Some(p) = &mut context.plugin {
1847            p.on_binding_end(&signal.value, context.writer)?;
1848        }
1849        Ok(())
1850    }
1851
1852    /// Write a signal value directly to the writer, avoiding intermediate String allocation.
1853    /// For HTML-escaped output, writes the Cow from `encode_safe` directly.
1854    fn write_signal_value(
1855        &self,
1856        value: &Value,
1857        raw: bool,
1858        writer: &mut dyn ResponseWriter,
1859    ) -> Result<()> {
1860        if raw {
1861            match value {
1862                Value::String(s) => writer.write(s),
1863                _ => writer.write(&value.to_string()),
1864            }
1865        } else {
1866            match value {
1867                Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
1868                _ => {
1869                    let s = value.to_string();
1870                    writer.write(&crate::html_encode::encode_safe(&s))
1871                }
1872            }
1873        }
1874    }
1875
1876    /// Process an if condition fragment.
1877    fn process_if(
1878        &self,
1879        if_cond: &webui_protocol::WebUIFragmentIf,
1880        context: &mut WebUIProcessContext,
1881    ) -> Result<()> {
1882        let condition = if_cond
1883            .condition
1884            .as_ref()
1885            .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
1886        let condition_met = self.evaluate_condition(condition, context)?;
1887
1888        if let Some(p) = &mut context.plugin {
1889            p.on_if_start(&if_cond.fragment_id, context.writer)?;
1890        }
1891
1892        if condition_met {
1893            if let Some(p) = &mut context.plugin {
1894                p.push_scope();
1895            }
1896
1897            self.process_fragment_id(&if_cond.fragment_id, context)?;
1898
1899            if let Some(p) = &mut context.plugin {
1900                p.pop_scope();
1901            }
1902        }
1903
1904        if let Some(p) = &mut context.plugin {
1905            p.on_if_end(&if_cond.fragment_id, context.writer)?;
1906        }
1907
1908        Ok(())
1909    }
1910
1911    /// Process an attribute fragment by rendering the attribute name/value pair.
1912    fn process_attribute(
1913        &self,
1914        attr: &webui_protocol::WebUIFragmentAttribute,
1915        context: &mut WebUIProcessContext,
1916    ) -> Result<()> {
1917        // Initialize component attribute accumulator on attrStart
1918        if attr.attr_start {
1919            context.component_attrs = HashMap::new();
1920        }
1921
1922        // Boolean attribute with condition tree
1923        if let Some(condition) = &attr.condition_tree {
1924            let condition_met = self.evaluate_condition(condition, context)?;
1925
1926            if !attr.attr_skip {
1927                let name = component_attr_name(&attr.name);
1928                context
1929                    .component_attrs
1930                    .insert(name, Value::Bool(condition_met));
1931            }
1932
1933            if condition_met {
1934                context.writer.write(" ")?;
1935                context.writer.write(&attr.name)?;
1936            }
1937            return Ok(());
1938        }
1939
1940        // Template attribute (mixed static + dynamic)
1941        if !attr.template.is_empty() {
1942            let raw_value = self.render_template_attr_value(&attr.template, context)?;
1943            let escaped = crate::html_encode::encode_safe(&raw_value);
1944            write_attr(context.writer, &attr.name, &escaped)?;
1945
1946            if !attr.attr_skip {
1947                let name = component_attr_name(&attr.name);
1948                context
1949                    .component_attrs
1950                    .insert(name, Value::String(raw_value));
1951            }
1952            return Ok(());
1953        }
1954
1955        // Simple attribute
1956        if !attr.value.is_empty() {
1957            if attr.raw_value {
1958                // Static attribute — value is the literal string
1959                write_attr(context.writer, &attr.name, &attr.value)?;
1960                if !attr.attr_skip {
1961                    let name = component_attr_name(&attr.name);
1962                    context
1963                        .component_attrs
1964                        .insert(name, Value::String(attr.value.clone()));
1965                }
1966            } else if attr.complex {
1967                // Complex attribute — resolve value, don't render to HTML, store as state
1968                if let Some(value) = self.resolve_value(&attr.value, context) {
1969                    if !attr.attr_skip {
1970                        let stripped = attr.name.strip_prefix(':').unwrap_or(&attr.name);
1971                        let name = component_attr_name(stripped);
1972                        context.component_attrs.insert(name, value);
1973                    }
1974                }
1975            } else {
1976                // Dynamic attribute — resolve and render
1977                let value = self.resolve_value(&attr.value, context);
1978                // Always emit the attribute so FAST hydration markers
1979                // (`data-fe`) match the DOM node structure.
1980                match &value {
1981                    Some(Value::String(s)) => {
1982                        write_attr(
1983                            context.writer,
1984                            &attr.name,
1985                            &crate::html_encode::encode_safe(s),
1986                        )?;
1987                    }
1988                    Some(Value::Null) | None => {
1989                        write_attr(context.writer, &attr.name, "")?;
1990                    }
1991                    Some(other) => {
1992                        let s = other.to_string();
1993                        write_attr(
1994                            context.writer,
1995                            &attr.name,
1996                            &crate::html_encode::encode_safe(&s),
1997                        )?;
1998                    }
1999                }
2000
2001                if !attr.attr_skip {
2002                    let name = component_attr_name(&attr.name);
2003                    context
2004                        .component_attrs
2005                        .insert(name, value.unwrap_or(Value::String(String::new())));
2006                }
2007            }
2008        }
2009
2010        Ok(())
2011    }
2012
2013    /// Render a template attribute's fragments into a raw (unescaped) string.
2014    fn render_template_attr_value(
2015        &self,
2016        template_id: &str,
2017        context: &WebUIProcessContext,
2018    ) -> Result<String> {
2019        let fragments = context
2020            .protocol
2021            .fragments
2022            .get(template_id)
2023            .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
2024        let mut raw_value = String::new();
2025        for frag in &fragments.fragments {
2026            match frag.fragment.as_ref() {
2027                Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
2028                Some(Fragment::Signal(signal)) => {
2029                    if let Some(value) = self.resolve_value(&signal.value, context) {
2030                        match &value {
2031                            Value::String(s) => raw_value.push_str(s),
2032                            _ => raw_value.push_str(&value.to_string()),
2033                        }
2034                    }
2035                }
2036                _ => {}
2037            }
2038        }
2039        Ok(raw_value)
2040    }
2041
2042    /// Render the UI based on the protocol and state.
2043    pub fn render<'a>(
2044        &self,
2045        protocol: &'a Protocol,
2046        state: &'a Value,
2047        options: &RenderOptions<'a>,
2048        writer: &'a mut dyn ResponseWriter,
2049    ) -> Result<()> {
2050        let document = protocol.protocol();
2051        if !document.fragments.contains_key(options.entry_id) {
2052            return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
2053        }
2054        let mut context = WebUIProcessContext {
2055            protocol: document,
2056            state,
2057            writer,
2058            local_vars: HashMap::new(),
2059            component_attrs: HashMap::new(),
2060            request_path: options.request_path,
2061            route_base: Cow::Borrowed("/"),
2062            rendered_components: HashSet::new(),
2063            plugin: self.plugin_factory.map(|f| f()),
2064            route_children: Vec::new(),
2065            entry_id: options.entry_id,
2066            // Same defensive normalisation as `handle()`. See the
2067            // doc-comment there for the CSP-outage rationale.
2068            nonce: options.nonce.filter(|s| !s.is_empty()),
2069            head_inject: options.head_inject.filter(|s| !s.is_empty()),
2070            body_inject: options.body_inject.filter(|s| !s.is_empty()),
2071            state_inject: StateInject::resolve(state),
2072            head_end_emitted: false,
2073            body_start_emitted: false,
2074            component_index: protocol.component_index(),
2075            body_end_emitted: false,
2076            route_index: protocol.route_index(),
2077            route_chain_index: 0,
2078            streaming: None,
2079            json_scratch: Vec::new(),
2080            scope_pool: Vec::new(),
2081        };
2082
2083        self.process_fragment_id(options.entry_id, &mut context)?;
2084        writer.end()?;
2085
2086        Ok(())
2087    }
2088}
2089
2090impl Default for WebUIHandler {
2091    fn default() -> Self {
2092        Self::new()
2093    }
2094}
2095
2096/// Write ` name="value"` to the writer without allocating a format string.
2097fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
2098    writer.write(" ")?;
2099    writer.write(name)?;
2100    writer.write("=\"")?;
2101    writer.write(value)?;
2102    writer.write("\"")
2103}
2104
2105#[cfg(test)]
2106fn handle(
2107    protocol: &WebUIProtocol,
2108    state: &Value,
2109    options: &RenderOptions<'_>,
2110    writer: &mut dyn ResponseWriter,
2111) -> Result<()> {
2112    let handler = WebUIHandler::new();
2113    handler.handle(protocol, state, options, writer)
2114}
2115
2116#[cfg(test)]
2117mod tests {
2118    use super::*;
2119    use crate::streaming::STREAMING_MARKER;
2120    use std::cell::RefCell;
2121    use std::sync::Arc;
2122    use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
2123    use webui_protocol::{
2124        web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
2125        WebUIFragmentAttribute,
2126    };
2127    use webui_test_utils::test_json;
2128
2129    fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
2130        WebUIFragment::signal(
2131            format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
2132            true,
2133        )
2134    }
2135
2136    // A simple test writer implementation
2137    struct TestWriter {
2138        content: RefCell<String>,
2139        ended: RefCell<bool>,
2140    }
2141
2142    impl TestWriter {
2143        fn new() -> Self {
2144            Self {
2145                content: RefCell::new(String::new()),
2146                ended: RefCell::new(false),
2147            }
2148        }
2149
2150        fn get_content(&self) -> String {
2151            self.content.borrow().clone()
2152        }
2153
2154        fn is_ended(&self) -> bool {
2155            *self.ended.borrow()
2156        }
2157    }
2158
2159    impl ResponseWriter for TestWriter {
2160        fn write(&mut self, content: &str) -> Result<()> {
2161            self.content.borrow_mut().push_str(content);
2162            Ok(())
2163        }
2164
2165        fn end(&mut self) -> Result<()> {
2166            *self.ended.borrow_mut() = true;
2167            Ok(())
2168        }
2169    }
2170
2171    #[test]
2172    fn test_handle_raw() {
2173        // Create a simple protocol
2174        let mut fragments = HashMap::new();
2175        fragments.insert(
2176            "index.html".to_string(),
2177            FragmentList {
2178                fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
2179            },
2180        );
2181
2182        let protocol = WebUIProtocol::new(fragments);
2183        let state = test_json!({});
2184
2185        // Create a test writer
2186        let mut writer = TestWriter::new();
2187
2188        // Handle the protocol
2189        assert!(
2190            handle(
2191                &protocol,
2192                &state,
2193                &RenderOptions::new("index.html", "/"),
2194                &mut writer
2195            )
2196            .is_ok(),
2197            "Failed to handle raw protocol"
2198        );
2199
2200        // Check the output
2201        assert_eq!(writer.get_content(), "Hello, WebUI!");
2202        assert!(writer.is_ended());
2203    }
2204
2205    #[test]
2206    fn test_handle_signal() {
2207        // Create a protocol with a signal
2208        let mut fragments = HashMap::new();
2209        fragments.insert(
2210            "index.html".to_string(),
2211            FragmentList {
2212                fragments: vec![
2213                    WebUIFragment::raw("Hello, "),
2214                    WebUIFragment::signal("name", false),
2215                    WebUIFragment::raw("!"),
2216                ],
2217            },
2218        );
2219
2220        let protocol = WebUIProtocol::new(fragments);
2221        let state = test_json!({"name": "WebUI"});
2222
2223        // Create a test writer
2224        let mut writer = TestWriter::new();
2225
2226        // Handle the protocol
2227        assert!(
2228            handle(
2229                &protocol,
2230                &state,
2231                &RenderOptions::new("index.html", "/"),
2232                &mut writer
2233            )
2234            .is_ok(),
2235            "Failed to handle signal protocol"
2236        );
2237
2238        // Check the output
2239        assert_eq!(writer.get_content(), "Hello, WebUI!");
2240        assert!(writer.is_ended());
2241    }
2242
2243    #[test]
2244    fn test_handle_for_loop() {
2245        // Create a protocol with a for loop
2246        let mut fragments = HashMap::new();
2247        fragments.insert(
2248            "index.html".to_string(),
2249            FragmentList {
2250                fragments: vec![
2251                    WebUIFragment::raw("People: "),
2252                    WebUIFragment::for_loop("person", "people", "person-item"),
2253                ],
2254            },
2255        );
2256
2257        fragments.insert(
2258            "person-item".to_string(),
2259            FragmentList {
2260                fragments: vec![
2261                    WebUIFragment::signal("person.name", false),
2262                    WebUIFragment::raw(", "),
2263                ],
2264            },
2265        );
2266
2267        let protocol = WebUIProtocol::new(fragments);
2268        let state = test_json!({
2269            "people": [
2270                {"name": "Alice"},
2271                {"name": "Bob"},
2272                {"name": "Charlie"}
2273            ]
2274        });
2275
2276        // Create a test writer
2277        let mut writer = TestWriter::new();
2278
2279        // Handle the protocol
2280        assert!(
2281            handle(
2282                &protocol,
2283                &state,
2284                &RenderOptions::new("index.html", "/"),
2285                &mut writer
2286            )
2287            .is_ok(),
2288            "Failed to handle for loop protocol"
2289        );
2290
2291        // Check the output
2292        assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
2293        assert!(writer.is_ended());
2294    }
2295
2296    #[test]
2297    fn test_handle_if_condition() {
2298        // Create a protocol with an if condition
2299        let mut fragments = HashMap::new();
2300        fragments.insert(
2301            "index.html".to_string(),
2302            FragmentList {
2303                fragments: vec![
2304                    WebUIFragment::raw("Status: "),
2305                    WebUIFragment::if_cond(
2306                        webui_protocol::ConditionExpr::identifier("isActive"),
2307                        "active-content",
2308                    ),
2309                    WebUIFragment::raw("End"),
2310                ],
2311            },
2312        );
2313
2314        fragments.insert(
2315            "active-content".to_string(),
2316            FragmentList {
2317                fragments: vec![WebUIFragment::raw("Active")],
2318            },
2319        );
2320
2321        let protocol = WebUIProtocol::new(fragments);
2322
2323        // Test with isActive = true
2324        let state_true = test_json!({"isActive": true});
2325        let mut writer_true = TestWriter::new();
2326        assert!(
2327            handle(
2328                &protocol,
2329                &state_true,
2330                &RenderOptions::new("index.html", "/"),
2331                &mut writer_true
2332            )
2333            .is_ok(),
2334            "Failed to handle if condition (true case)"
2335        );
2336        assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
2337        assert!(writer_true.is_ended());
2338
2339        // Test with isActive = false
2340        let state_false = test_json!({"isActive": false});
2341        let mut writer_false = TestWriter::new();
2342        assert!(
2343            handle(
2344                &protocol,
2345                &state_false,
2346                &RenderOptions::new("index.html", "/"),
2347                &mut writer_false
2348            )
2349            .is_ok(),
2350            "Failed to handle if condition (false case)"
2351        );
2352        assert_eq!(writer_false.get_content(), "Status: End");
2353        assert!(writer_false.is_ended());
2354    }
2355
2356    #[test]
2357    fn test_handle_component() {
2358        // Create a protocol with a component
2359        let mut fragments = HashMap::new();
2360        fragments.insert(
2361            "index.html".to_string(),
2362            FragmentList {
2363                fragments: vec![
2364                    WebUIFragment::raw("Component: "),
2365                    WebUIFragment::component("my-component"),
2366                ],
2367            },
2368        );
2369
2370        fragments.insert(
2371            "my-component".to_string(),
2372            FragmentList {
2373                fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
2374            },
2375        );
2376
2377        let protocol = WebUIProtocol::new(fragments);
2378        let state = test_json!({});
2379
2380        // Create a test writer
2381        let mut writer = TestWriter::new();
2382
2383        // Handle the protocol
2384        assert!(
2385            handle(
2386                &protocol,
2387                &state,
2388                &RenderOptions::new("index.html", "/"),
2389                &mut writer
2390            )
2391            .is_ok(),
2392            "Failed to handle component protocol"
2393        );
2394
2395        // Check the output
2396        assert_eq!(
2397            writer.get_content(),
2398            "Component: <div>Component Content</div>"
2399        );
2400        assert!(writer.is_ended());
2401    }
2402
2403    #[test]
2404    fn test_missing_fragment() {
2405        // Create a protocol with a missing fragment reference
2406        let mut fragments = HashMap::new();
2407        fragments.insert(
2408            "index.html".to_string(),
2409            FragmentList {
2410                fragments: vec![WebUIFragment::component("missing-component")],
2411            },
2412        );
2413
2414        let protocol = WebUIProtocol::new(fragments);
2415        let state = test_json!({});
2416
2417        // Create a test writer
2418        let mut writer = TestWriter::new();
2419
2420        // Handle the protocol
2421        let result = handle(
2422            &protocol,
2423            &state,
2424            &RenderOptions::new("index.html", "/"),
2425            &mut writer,
2426        );
2427
2428        // Expect an error
2429        assert!(result.is_err());
2430        if let Err(HandlerError::MissingFragment(fragment_id)) = result {
2431            assert_eq!(fragment_id, "missing-component");
2432        } else {
2433            panic!("Expected MissingFragment error");
2434        }
2435    }
2436
2437    #[test]
2438    fn test_missing_signal_renders_empty() {
2439        // A signal referencing a field absent from state should render as empty
2440        let mut fragments = HashMap::new();
2441        fragments.insert(
2442            "index.html".to_string(),
2443            FragmentList {
2444                fragments: vec![
2445                    WebUIFragment::raw("Hello, "),
2446                    WebUIFragment::signal("missing_field", false),
2447                    WebUIFragment::raw("!"),
2448                ],
2449            },
2450        );
2451
2452        let protocol = WebUIProtocol::new(fragments);
2453        let state = test_json!({});
2454
2455        let mut writer = TestWriter::new();
2456
2457        assert!(
2458            handle(
2459                &protocol,
2460                &state,
2461                &RenderOptions::new("index.html", "/"),
2462                &mut writer
2463            )
2464            .is_ok(),
2465            "Missing signal should not produce an error"
2466        );
2467
2468        assert_eq!(writer.get_content(), "Hello, !");
2469        assert!(writer.is_ended());
2470    }
2471
2472    // ── Boolean attribute rendering tests ─────────────────────────────
2473
2474    #[test]
2475    fn test_boolean_attr_true() {
2476        let mut fragments = HashMap::new();
2477        fragments.insert(
2478            "index.html".to_string(),
2479            FragmentList {
2480                fragments: vec![
2481                    WebUIFragment::raw("<button"),
2482                    WebUIFragment::attribute_boolean(
2483                        "disabled",
2484                        ConditionExpr::identifier("isDisabled"),
2485                    ),
2486                    WebUIFragment::raw(">Click</button>"),
2487                ],
2488            },
2489        );
2490        let protocol = WebUIProtocol::new(fragments);
2491        let state = test_json!({"isDisabled": true});
2492        let mut writer = TestWriter::new();
2493        handle(
2494            &protocol,
2495            &state,
2496            &RenderOptions::new("index.html", "/"),
2497            &mut writer,
2498        )
2499        .unwrap();
2500        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
2501    }
2502
2503    #[test]
2504    fn test_boolean_attr_false() {
2505        let mut fragments = HashMap::new();
2506        fragments.insert(
2507            "index.html".to_string(),
2508            FragmentList {
2509                fragments: vec![
2510                    WebUIFragment::raw("<button"),
2511                    WebUIFragment::attribute_boolean(
2512                        "disabled",
2513                        ConditionExpr::identifier("isDisabled"),
2514                    ),
2515                    WebUIFragment::raw(">Click</button>"),
2516                ],
2517            },
2518        );
2519        let protocol = WebUIProtocol::new(fragments);
2520        let state = test_json!({"isDisabled": false});
2521        let mut writer = TestWriter::new();
2522        handle(
2523            &protocol,
2524            &state,
2525            &RenderOptions::new("index.html", "/"),
2526            &mut writer,
2527        )
2528        .unwrap();
2529        assert_eq!(writer.get_content(), "<button>Click</button>");
2530    }
2531
2532    #[test]
2533    fn test_boolean_attr_missing() {
2534        let mut fragments = HashMap::new();
2535        fragments.insert(
2536            "index.html".to_string(),
2537            FragmentList {
2538                fragments: vec![
2539                    WebUIFragment::raw("<input type=\"checkbox\""),
2540                    WebUIFragment::attribute_boolean(
2541                        "checked",
2542                        ConditionExpr::identifier("checked"),
2543                    ),
2544                    WebUIFragment::raw(">"),
2545                ],
2546            },
2547        );
2548        let protocol = WebUIProtocol::new(fragments);
2549        let state = test_json!({});
2550        let mut writer = TestWriter::new();
2551        handle(
2552            &protocol,
2553            &state,
2554            &RenderOptions::new("index.html", "/"),
2555            &mut writer,
2556        )
2557        .unwrap();
2558        assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
2559    }
2560
2561    #[test]
2562    fn test_boolean_attr_multiple() {
2563        let mut fragments = HashMap::new();
2564        fragments.insert(
2565            "index.html".to_string(),
2566            FragmentList {
2567                fragments: vec![
2568                    WebUIFragment::raw("<input type=\"checkbox\""),
2569                    WebUIFragment::attribute_boolean(
2570                        "checked",
2571                        ConditionExpr::identifier("checked"),
2572                    ),
2573                    WebUIFragment::attribute_boolean(
2574                        "disabled",
2575                        ConditionExpr::identifier("disabled"),
2576                    ),
2577                    WebUIFragment::raw(">"),
2578                ],
2579            },
2580        );
2581        let protocol = WebUIProtocol::new(fragments);
2582        let state = test_json!({"checked": true, "disabled": false});
2583        let mut writer = TestWriter::new();
2584        handle(
2585            &protocol,
2586            &state,
2587            &RenderOptions::new("index.html", "/"),
2588            &mut writer,
2589        )
2590        .unwrap();
2591        assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
2592    }
2593
2594    // ── Simple attribute rendering tests ──────────────────────────────
2595
2596    #[test]
2597    fn test_attribute_with_value() {
2598        let mut fragments = HashMap::new();
2599        fragments.insert(
2600            "index.html".to_string(),
2601            FragmentList {
2602                fragments: vec![
2603                    WebUIFragment::raw("<input"),
2604                    WebUIFragment::attribute("value", "inputValue"),
2605                    WebUIFragment::raw(">"),
2606                ],
2607            },
2608        );
2609        let protocol = WebUIProtocol::new(fragments);
2610        let state = test_json!({"inputValue": "Hello"});
2611        let mut writer = TestWriter::new();
2612        handle(
2613            &protocol,
2614            &state,
2615            &RenderOptions::new("index.html", "/"),
2616            &mut writer,
2617        )
2618        .unwrap();
2619        assert_eq!(writer.get_content(), "<input value=\"Hello\">");
2620    }
2621
2622    #[test]
2623    fn test_attribute_with_falsy_numeric() {
2624        let mut fragments = HashMap::new();
2625        fragments.insert(
2626            "index.html".to_string(),
2627            FragmentList {
2628                fragments: vec![
2629                    WebUIFragment::raw("<div name=\"test\""),
2630                    WebUIFragment::attribute("handle", "number"),
2631                    WebUIFragment::raw("></div>"),
2632                ],
2633            },
2634        );
2635        let protocol = WebUIProtocol::new(fragments);
2636        let state = test_json!({"number": 0});
2637        let mut writer = TestWriter::new();
2638        handle(
2639            &protocol,
2640            &state,
2641            &RenderOptions::new("index.html", "/"),
2642            &mut writer,
2643        )
2644        .unwrap();
2645        assert_eq!(
2646            writer.get_content(),
2647            "<div name=\"test\" handle=\"0\"></div>"
2648        );
2649    }
2650
2651    // ── Dynamic attribute escaping for non-string JSON types ─────────
2652
2653    #[test]
2654    fn test_attribute_array_value_is_escaped() {
2655        let mut fragments = HashMap::new();
2656        fragments.insert(
2657            "index.html".to_string(),
2658            FragmentList {
2659                fragments: vec![
2660                    WebUIFragment::raw("<a"),
2661                    WebUIFragment::attribute("href", "value"),
2662                    WebUIFragment::raw(">demo</a>"),
2663                ],
2664            },
2665        );
2666        let protocol = WebUIProtocol::new(fragments);
2667        let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
2668        let mut writer = TestWriter::new();
2669        handle(
2670            &protocol,
2671            &state,
2672            &RenderOptions::new("index.html", "/"),
2673            &mut writer,
2674        )
2675        .unwrap();
2676        let content = writer.get_content();
2677        // All inner double quotes must be entity-escaped so that the
2678        // browser never sees a second attribute boundary.
2679        assert!(
2680            content.contains("&quot;"),
2681            "Double quotes inside attribute value must be escaped: {content}"
2682        );
2683        // The href attribute value must be a single contiguous quoted
2684        // string — no extra attributes should appear.
2685        assert_eq!(
2686            content.matches("=\"").count(),
2687            1,
2688            "Only one attribute assignment expected: {content}"
2689        );
2690    }
2691
2692    #[test]
2693    fn test_attribute_object_value_is_escaped() {
2694        let mut fragments = HashMap::new();
2695        fragments.insert(
2696            "index.html".to_string(),
2697            FragmentList {
2698                fragments: vec![
2699                    WebUIFragment::raw("<div"),
2700                    WebUIFragment::attribute("data-cfg", "cfg"),
2701                    WebUIFragment::raw("></div>"),
2702                ],
2703            },
2704        );
2705        let protocol = WebUIProtocol::new(fragments);
2706        let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
2707        let mut writer = TestWriter::new();
2708        handle(
2709            &protocol,
2710            &state,
2711            &RenderOptions::new("index.html", "/"),
2712            &mut writer,
2713        )
2714        .unwrap();
2715        let content = writer.get_content();
2716        assert!(
2717            content.contains("&quot;"),
2718            "Double quotes inside attribute value must be escaped: {content}"
2719        );
2720        assert_eq!(
2721            content.matches("=\"").count(),
2722            1,
2723            "Only one attribute assignment expected: {content}"
2724        );
2725    }
2726
2727    // ── Template attribute rendering tests ────────────────────────────
2728
2729    #[test]
2730    fn test_mixed_attribute_template() {
2731        let mut fragments = HashMap::new();
2732        fragments.insert(
2733            "index.html".to_string(),
2734            FragmentList {
2735                fragments: vec![
2736                    WebUIFragment::raw("<input"),
2737                    WebUIFragment::attribute_template("value", "attr-1"),
2738                    WebUIFragment::raw(">"),
2739                ],
2740            },
2741        );
2742        fragments.insert(
2743            "attr-1".to_string(),
2744            FragmentList {
2745                fragments: vec![
2746                    WebUIFragment::raw("hello "),
2747                    WebUIFragment::signal("item", false),
2748                ],
2749            },
2750        );
2751        let protocol = WebUIProtocol::new(fragments);
2752        let state = test_json!({"item": "world"});
2753        let mut writer = TestWriter::new();
2754        handle(
2755            &protocol,
2756            &state,
2757            &RenderOptions::new("index.html", "/"),
2758            &mut writer,
2759        )
2760        .unwrap();
2761        assert_eq!(writer.get_content(), "<input value=\"hello world\">");
2762    }
2763
2764    // ── Raw signal rendering test ─────────────────────────────────────
2765
2766    #[test]
2767    fn test_raw_signal_not_escaped() {
2768        let mut fragments = HashMap::new();
2769        fragments.insert(
2770            "index.html".to_string(),
2771            FragmentList {
2772                fragments: vec![
2773                    WebUIFragment::signal("html", false),
2774                    WebUIFragment::signal("html", true),
2775                ],
2776            },
2777        );
2778        let protocol = WebUIProtocol::new(fragments);
2779        let state = test_json!({"html": "<strong>hi</strong>"});
2780        let mut writer = TestWriter::new();
2781        handle(
2782            &protocol,
2783            &state,
2784            &RenderOptions::new("index.html", "/"),
2785            &mut writer,
2786        )
2787        .unwrap();
2788        assert_eq!(
2789            writer.get_content(),
2790            "&lt;strong&gt;hi&lt;&#x2F;strong&gt;<strong>hi</strong>"
2791        );
2792    }
2793
2794    // ── Nested for loop tests ─────────────────────────────────────────
2795
2796    #[test]
2797    fn test_nested_for_loop() {
2798        let mut fragments = HashMap::new();
2799        fragments.insert(
2800            "index.html".to_string(),
2801            FragmentList {
2802                fragments: vec![
2803                    WebUIFragment::raw("<div>"),
2804                    WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
2805                    WebUIFragment::raw("</div>"),
2806                ],
2807            },
2808        );
2809        fragments.insert(
2810            "outer".to_string(),
2811            FragmentList {
2812                fragments: vec![
2813                    WebUIFragment::raw("<div>"),
2814                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
2815                    WebUIFragment::raw("</div>"),
2816                ],
2817            },
2818        );
2819        fragments.insert(
2820            "inner".to_string(),
2821            FragmentList {
2822                fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
2823            },
2824        );
2825        let protocol = WebUIProtocol::new(fragments);
2826        let state = test_json!({
2827            "outerItems": [
2828                {"innerItems": [{"name": "A"}, {"name": "B"}]},
2829                {"innerItems": [{"name": "C"}]}
2830            ]
2831        });
2832        let mut writer = TestWriter::new();
2833        handle(
2834            &protocol,
2835            &state,
2836            &RenderOptions::new("index.html", "/"),
2837            &mut writer,
2838        )
2839        .unwrap();
2840        assert_eq!(
2841            writer.get_content(),
2842            "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
2843        );
2844    }
2845
2846    #[test]
2847    fn test_nested_for_with_signals() {
2848        let mut fragments = HashMap::new();
2849        fragments.insert(
2850            "index.html".to_string(),
2851            FragmentList {
2852                fragments: vec![
2853                    WebUIFragment::raw("<div>"),
2854                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
2855                    WebUIFragment::raw("</div>"),
2856                ],
2857            },
2858        );
2859        fragments.insert(
2860            "outerTemplate".to_string(),
2861            FragmentList {
2862                fragments: vec![
2863                    WebUIFragment::raw("<div>"),
2864                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
2865                    WebUIFragment::raw("</div>"),
2866                ],
2867            },
2868        );
2869        fragments.insert(
2870            "innerTemplate".to_string(),
2871            FragmentList {
2872                fragments: vec![
2873                    WebUIFragment::raw("<span>"),
2874                    WebUIFragment::signal("innerItem.name", false),
2875                    WebUIFragment::raw("</span>"),
2876                ],
2877            },
2878        );
2879        let protocol = WebUIProtocol::new(fragments);
2880        let state = test_json!({
2881            "outerItems": [
2882                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
2883                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
2884            ]
2885        });
2886        let mut writer = TestWriter::new();
2887        handle(
2888            &protocol,
2889            &state,
2890            &RenderOptions::new("index.html", "/"),
2891            &mut writer,
2892        )
2893        .unwrap();
2894        assert_eq!(
2895            writer.get_content(),
2896            "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
2897        );
2898    }
2899
2900    #[test]
2901    fn test_nested_for_with_global_state() {
2902        let mut fragments = HashMap::new();
2903        fragments.insert(
2904            "index.html".to_string(),
2905            FragmentList {
2906                fragments: vec![
2907                    WebUIFragment::raw("<div>"),
2908                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
2909                    WebUIFragment::raw("</div>"),
2910                ],
2911            },
2912        );
2913        fragments.insert(
2914            "outerTemplate".to_string(),
2915            FragmentList {
2916                fragments: vec![
2917                    WebUIFragment::raw("<div>"),
2918                    WebUIFragment::signal("globalOuter", false),
2919                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
2920                    WebUIFragment::raw("</div>"),
2921                ],
2922            },
2923        );
2924        fragments.insert(
2925            "innerTemplate".to_string(),
2926            FragmentList {
2927                fragments: vec![
2928                    WebUIFragment::raw("<span>"),
2929                    WebUIFragment::signal("innerItem.name", false),
2930                    WebUIFragment::signal("globalInner", false),
2931                    WebUIFragment::raw("</span>"),
2932                ],
2933            },
2934        );
2935        let protocol = WebUIProtocol::new(fragments);
2936        let state = test_json!({
2937            "globalOuter": "GO",
2938            "globalInner": "GI",
2939            "outerItems": [
2940                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
2941                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
2942            ]
2943        });
2944        let mut writer = TestWriter::new();
2945        handle(
2946            &protocol,
2947            &state,
2948            &RenderOptions::new("index.html", "/"),
2949            &mut writer,
2950        )
2951        .unwrap();
2952        assert_eq!(
2953            writer.get_content(),
2954            "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
2955        );
2956    }
2957
2958    // ── For + If state scoping tests ──────────────────────────────────
2959
2960    #[test]
2961    fn test_if_in_for_uses_local_state() {
2962        let mut fragments = HashMap::new();
2963        fragments.insert(
2964            "index.html".to_string(),
2965            FragmentList {
2966                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
2967            },
2968        );
2969        fragments.insert(
2970            "item-tpl".to_string(),
2971            FragmentList {
2972                fragments: vec![WebUIFragment::if_cond(
2973                    ConditionExpr::identifier("item.visible"),
2974                    "visible-tpl",
2975                )],
2976            },
2977        );
2978        fragments.insert(
2979            "visible-tpl".to_string(),
2980            FragmentList {
2981                fragments: vec![WebUIFragment::signal("item.name", false)],
2982            },
2983        );
2984        let protocol = WebUIProtocol::new(fragments);
2985        let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
2986        let mut writer = TestWriter::new();
2987        handle(
2988            &protocol,
2989            &state,
2990            &RenderOptions::new("index.html", "/"),
2991            &mut writer,
2992        )
2993        .unwrap();
2994        assert_eq!(writer.get_content(), "Show");
2995    }
2996
2997    #[test]
2998    fn test_for_if_local_overrides_global() {
2999        let mut fragments = HashMap::new();
3000        fragments.insert(
3001            "index.html".to_string(),
3002            FragmentList {
3003                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
3004            },
3005        );
3006        fragments.insert(
3007            "item-tpl".to_string(),
3008            FragmentList {
3009                fragments: vec![WebUIFragment::if_cond(
3010                    ConditionExpr::identifier("item.flag"),
3011                    "show-tpl",
3012                )],
3013            },
3014        );
3015        fragments.insert(
3016            "show-tpl".to_string(),
3017            FragmentList {
3018                fragments: vec![WebUIFragment::raw("yes")],
3019            },
3020        );
3021        let protocol = WebUIProtocol::new(fragments);
3022        // Global flag is true, but local item.flag is false for second item
3023        let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
3024        let mut writer = TestWriter::new();
3025        handle(
3026            &protocol,
3027            &state,
3028            &RenderOptions::new("index.html", "/"),
3029            &mut writer,
3030        )
3031        .unwrap();
3032        assert_eq!(writer.get_content(), "yes");
3033    }
3034
3035    // ── Component attribute state tests ───────────────────────────────
3036
3037    #[test]
3038    fn test_component_attr_state_simple() {
3039        let mut fragments = HashMap::new();
3040        fragments.insert(
3041            "index.html".to_string(),
3042            FragmentList {
3043                fragments: vec![
3044                    WebUIFragment::raw("<my-comp"),
3045                    WebUIFragment {
3046                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3047                            WebUIFragmentAttribute {
3048                                name: "title".into(),
3049                                value: "Attribute Title".into(),
3050                                attr_start: true,
3051                                raw_value: true,
3052                                ..Default::default()
3053                            },
3054                        )),
3055                    },
3056                    WebUIFragment::raw(">"),
3057                    WebUIFragment::component("my-comp"),
3058                    WebUIFragment::raw("</my-comp>"),
3059                ],
3060            },
3061        );
3062        fragments.insert(
3063            "my-comp".to_string(),
3064            FragmentList {
3065                fragments: vec![
3066                    WebUIFragment::raw("<span>"),
3067                    WebUIFragment::signal("title", false),
3068                    WebUIFragment::raw("</span>"),
3069                ],
3070            },
3071        );
3072        let protocol = WebUIProtocol::new(fragments);
3073        let state = test_json!({"title": "Global Title"});
3074        let mut writer = TestWriter::new();
3075        handle(
3076            &protocol,
3077            &state,
3078            &RenderOptions::new("index.html", "/"),
3079            &mut writer,
3080        )
3081        .unwrap();
3082        assert_eq!(
3083            writer.get_content(),
3084            "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
3085        );
3086    }
3087
3088    #[test]
3089    fn test_component_attr_state_template() {
3090        let mut fragments = HashMap::new();
3091        fragments.insert(
3092            "index.html".to_string(),
3093            FragmentList {
3094                fragments: vec![
3095                    WebUIFragment::raw("<my-comp"),
3096                    WebUIFragment {
3097                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3098                            WebUIFragmentAttribute {
3099                                name: "title".into(),
3100                                template: "title-attr".into(),
3101                                attr_start: true,
3102                                ..Default::default()
3103                            },
3104                        )),
3105                    },
3106                    WebUIFragment::raw(">"),
3107                    WebUIFragment::component("my-comp"),
3108                    WebUIFragment::raw("</my-comp>"),
3109                ],
3110            },
3111        );
3112        fragments.insert(
3113            "title-attr".to_string(),
3114            FragmentList {
3115                fragments: vec![
3116                    WebUIFragment::raw("hello "),
3117                    WebUIFragment::signal("item", false),
3118                ],
3119            },
3120        );
3121        fragments.insert(
3122            "my-comp".to_string(),
3123            FragmentList {
3124                fragments: vec![
3125                    WebUIFragment::raw("<span>"),
3126                    WebUIFragment::signal("title", false),
3127                    WebUIFragment::raw("</span>"),
3128                ],
3129            },
3130        );
3131        let protocol = WebUIProtocol::new(fragments);
3132        let state = test_json!({"item": "<world>"});
3133        let mut writer = TestWriter::new();
3134        handle(
3135            &protocol,
3136            &state,
3137            &RenderOptions::new("index.html", "/"),
3138            &mut writer,
3139        )
3140        .unwrap();
3141        assert_eq!(
3142            writer.get_content(),
3143            "<my-comp title=\"hello &lt;world&gt;\"><span>hello &lt;world&gt;</span></my-comp>"
3144        );
3145    }
3146
3147    #[test]
3148    fn test_component_attr_camel_case() {
3149        let mut fragments = HashMap::new();
3150        fragments.insert(
3151            "index.html".to_string(),
3152            FragmentList {
3153                fragments: vec![
3154                    WebUIFragment::raw("<my-comp"),
3155                    WebUIFragment {
3156                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3157                            WebUIFragmentAttribute {
3158                                name: "data-title".into(),
3159                                template: "dt-attr".into(),
3160                                attr_start: true,
3161                                ..Default::default()
3162                            },
3163                        )),
3164                    },
3165                    WebUIFragment::raw(">"),
3166                    WebUIFragment::component("my-comp"),
3167                    WebUIFragment::raw("</my-comp>"),
3168                ],
3169            },
3170        );
3171        fragments.insert(
3172            "dt-attr".to_string(),
3173            FragmentList {
3174                fragments: vec![
3175                    WebUIFragment::raw("prefix "),
3176                    WebUIFragment::signal("item", false),
3177                ],
3178            },
3179        );
3180        fragments.insert(
3181            "my-comp".to_string(),
3182            FragmentList {
3183                fragments: vec![
3184                    WebUIFragment::raw("<span>"),
3185                    WebUIFragment::signal("dataTitle", false),
3186                    WebUIFragment::raw("</span>"),
3187                ],
3188            },
3189        );
3190        let protocol = WebUIProtocol::new(fragments);
3191        let state = test_json!({"item": "a&b"});
3192        let mut writer = TestWriter::new();
3193        handle(
3194            &protocol,
3195            &state,
3196            &RenderOptions::new("index.html", "/"),
3197            &mut writer,
3198        )
3199        .unwrap();
3200        assert_eq!(
3201            writer.get_content(),
3202            "<my-comp data-title=\"prefix a&amp;b\"><span>prefix a&amp;b</span></my-comp>"
3203        );
3204    }
3205
3206    #[test]
3207    fn test_component_complex_attr() {
3208        let mut fragments = HashMap::new();
3209        fragments.insert(
3210            "index.html".to_string(),
3211            FragmentList {
3212                fragments: vec![
3213                    WebUIFragment::raw("<my-comp"),
3214                    WebUIFragment {
3215                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3216                            WebUIFragmentAttribute {
3217                                name: ":item".into(),
3218                                value: "complexItem".into(),
3219                                attr_start: true,
3220                                complex: true,
3221                                ..Default::default()
3222                            },
3223                        )),
3224                    },
3225                    WebUIFragment::raw(">"),
3226                    WebUIFragment::component("my-comp"),
3227                    WebUIFragment::raw("</my-comp>"),
3228                ],
3229            },
3230        );
3231        fragments.insert(
3232            "my-comp".to_string(),
3233            FragmentList {
3234                fragments: vec![
3235                    WebUIFragment::raw("<span>"),
3236                    WebUIFragment::signal("item.foo", false),
3237                    WebUIFragment::raw("</span><p>"),
3238                    WebUIFragment::signal("item.bar", false),
3239                    WebUIFragment::raw("</p>"),
3240                ],
3241            },
3242        );
3243        let protocol = WebUIProtocol::new(fragments);
3244        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
3245        let mut writer = TestWriter::new();
3246        handle(
3247            &protocol,
3248            &state,
3249            &RenderOptions::new("index.html", "/"),
3250            &mut writer,
3251        )
3252        .unwrap();
3253        assert_eq!(
3254            writer.get_content(),
3255            "<my-comp><span>1</span><p>true</p></my-comp>"
3256        );
3257    }
3258
3259    #[test]
3260    fn test_component_no_parent_pollution() {
3261        let mut fragments = HashMap::new();
3262        fragments.insert(
3263            "index.html".to_string(),
3264            FragmentList {
3265                fragments: vec![
3266                    WebUIFragment::raw("<parent"),
3267                    WebUIFragment {
3268                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3269                            WebUIFragmentAttribute {
3270                                name: "var".into(),
3271                                value: "var".into(),
3272                                attr_start: true,
3273                                ..Default::default()
3274                            },
3275                        )),
3276                    },
3277                    WebUIFragment::raw(">"),
3278                    WebUIFragment::component("parent"),
3279                    WebUIFragment::raw("</parent>"),
3280                ],
3281            },
3282        );
3283        fragments.insert(
3284            "parent".to_string(),
3285            FragmentList {
3286                fragments: vec![
3287                    WebUIFragment::raw("Before: "),
3288                    WebUIFragment::signal("var", false),
3289                    WebUIFragment::raw("<child foo"),
3290                    WebUIFragment {
3291                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3292                            WebUIFragmentAttribute {
3293                                name: "var".into(),
3294                                value: "replaced".into(),
3295                                raw_value: true,
3296                                ..Default::default()
3297                            },
3298                        )),
3299                    },
3300                    WebUIFragment::raw(">"),
3301                    WebUIFragment::component("child"),
3302                    WebUIFragment::raw("Label</child>After: "),
3303                    WebUIFragment::signal("var", false),
3304                ],
3305            },
3306        );
3307        fragments.insert("child".to_string(), FragmentList { fragments: vec![] });
3308        let protocol = WebUIProtocol::new(fragments);
3309        let state = test_json!({"var": "original"});
3310        let mut writer = TestWriter::new();
3311        handle(
3312            &protocol,
3313            &state,
3314            &RenderOptions::new("index.html", "/"),
3315            &mut writer,
3316        )
3317        .unwrap();
3318        assert_eq!(
3319            writer.get_content(),
3320            "<parent var=\"original\">Before: original<child foo var=\"replaced\">Label</child>After: original</parent>"
3321        );
3322    }
3323
3324    #[test]
3325    fn test_component_boolean_attr_state() {
3326        let mut fragments = HashMap::new();
3327        fragments.insert(
3328            "index.html".to_string(),
3329            FragmentList {
3330                fragments: vec![
3331                    WebUIFragment::raw("<my-comp"),
3332                    WebUIFragment {
3333                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3334                            WebUIFragmentAttribute {
3335                                name: "disabled".into(),
3336                                attr_start: true,
3337                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
3338                                ..Default::default()
3339                            },
3340                        )),
3341                    },
3342                    WebUIFragment::raw(">"),
3343                    WebUIFragment::component("my-comp"),
3344                    WebUIFragment::raw("</my-comp>"),
3345                ],
3346            },
3347        );
3348        fragments.insert(
3349            "my-comp".to_string(),
3350            FragmentList {
3351                fragments: vec![WebUIFragment::if_cond(
3352                    ConditionExpr::identifier("disabled"),
3353                    "show",
3354                )],
3355            },
3356        );
3357        fragments.insert(
3358            "show".to_string(),
3359            FragmentList {
3360                fragments: vec![WebUIFragment::raw("disabled!")],
3361            },
3362        );
3363        let protocol = WebUIProtocol::new(fragments);
3364        let state = test_json!({"isDisabled": true});
3365        let mut writer = TestWriter::new();
3366        handle(
3367            &protocol,
3368            &state,
3369            &RenderOptions::new("index.html", "/"),
3370            &mut writer,
3371        )
3372        .unwrap();
3373        assert_eq!(
3374            writer.get_content(),
3375            "<my-comp disabled>disabled!</my-comp>"
3376        );
3377    }
3378
3379    // ===== HTML Escape Tests (ported from utils.test.js escapeHtml) =====
3380
3381    /// Helper: render a signal value through the handler and return the escaped output.
3382    fn render_signal(value: &str) -> String {
3383        let mut fragments = HashMap::new();
3384        fragments.insert(
3385            "index.html".to_string(),
3386            FragmentList {
3387                fragments: vec![WebUIFragment::signal("v", false)],
3388            },
3389        );
3390        let protocol = WebUIProtocol::new(fragments);
3391        let state = test_json!({"v": value});
3392        let mut writer = TestWriter::new();
3393        handle(
3394            &protocol,
3395            &state,
3396            &RenderOptions::new("index.html", "/"),
3397            &mut writer,
3398        )
3399        .unwrap();
3400        writer.get_content()
3401    }
3402
3403    #[test]
3404    fn test_escape_ampersand() {
3405        assert_eq!(render_signal("&"), "&amp;");
3406    }
3407
3408    #[test]
3409    fn test_escape_less_than() {
3410        assert_eq!(render_signal("<"), "&lt;");
3411    }
3412
3413    #[test]
3414    fn test_escape_greater_than() {
3415        assert_eq!(render_signal(">"), "&gt;");
3416    }
3417
3418    #[test]
3419    fn test_escape_double_quote() {
3420        assert_eq!(render_signal("\""), "&quot;");
3421    }
3422
3423    #[test]
3424    fn test_escape_single_quote() {
3425        // encode_safe escapes ' as &#x27;
3426        let result = render_signal("'");
3427        assert_eq!(
3428            result, "&#x27;",
3429            "Expected &#x27; for single quote, got: {result}"
3430        );
3431    }
3432
3433    #[test]
3434    fn test_escape_multiple_special_chars() {
3435        let result = render_signal("<script>alert('xss');</script>");
3436        assert!(
3437            result.contains("&lt;") && result.contains("&gt;"),
3438            "Expected escaped HTML, got: {}",
3439            result
3440        );
3441        assert!(
3442            !result.contains("<script>"),
3443            "Should not contain raw <script> tag"
3444        );
3445    }
3446
3447    #[test]
3448    fn test_escape_no_special_chars() {
3449        assert_eq!(render_signal("Hello World"), "Hello World");
3450    }
3451
3452    #[test]
3453    fn test_escape_empty_string() {
3454        assert_eq!(render_signal(""), "");
3455    }
3456
3457    #[test]
3458    fn test_escape_special_at_beginning() {
3459        let result = render_signal("<Hello");
3460        assert!(
3461            result.starts_with("&lt;"),
3462            "Expected &lt; at start, got: {}",
3463            result
3464        );
3465    }
3466
3467    #[test]
3468    fn test_escape_special_at_end() {
3469        let result = render_signal("Hello>");
3470        assert!(
3471            result.ends_with("&gt;"),
3472            "Expected &gt; at end, got: {}",
3473            result
3474        );
3475    }
3476
3477    #[test]
3478    fn test_escape_special_in_middle() {
3479        let result = render_signal("Hel&lo");
3480        assert!(
3481            result.contains("&amp;"),
3482            "Expected &amp; in middle, got: {}",
3483            result
3484        );
3485    }
3486
3487    // ── GROUP 5: Boolean Attribute Edge Cases ─────────────────────────
3488
3489    #[test]
3490    fn test_boolean_attr_truthy_values() {
3491        // checked: 1
3492        {
3493            let mut fragments = HashMap::new();
3494            fragments.insert(
3495                "index.html".to_string(),
3496                FragmentList {
3497                    fragments: vec![
3498                        WebUIFragment::raw("<input"),
3499                        WebUIFragment::attribute_boolean(
3500                            "checked",
3501                            ConditionExpr::identifier("checked"),
3502                        ),
3503                        WebUIFragment::raw(">"),
3504                    ],
3505                },
3506            );
3507            let protocol = WebUIProtocol::new(fragments);
3508            let state = test_json!({"checked": 1});
3509            let mut writer = TestWriter::new();
3510            handle(
3511                &protocol,
3512                &state,
3513                &RenderOptions::new("index.html", "/"),
3514                &mut writer,
3515            )
3516            .unwrap();
3517            assert_eq!(writer.get_content(), "<input checked>");
3518        }
3519        // checked: "yes"
3520        {
3521            let mut fragments = HashMap::new();
3522            fragments.insert(
3523                "index.html".to_string(),
3524                FragmentList {
3525                    fragments: vec![
3526                        WebUIFragment::raw("<input"),
3527                        WebUIFragment::attribute_boolean(
3528                            "checked",
3529                            ConditionExpr::identifier("checked"),
3530                        ),
3531                        WebUIFragment::raw(">"),
3532                    ],
3533                },
3534            );
3535            let protocol = WebUIProtocol::new(fragments);
3536            let state = test_json!({"checked": "yes"});
3537            let mut writer = TestWriter::new();
3538            handle(
3539                &protocol,
3540                &state,
3541                &RenderOptions::new("index.html", "/"),
3542                &mut writer,
3543            )
3544            .unwrap();
3545            assert_eq!(writer.get_content(), "<input checked>");
3546        }
3547        // checked: {} (empty object is truthy)
3548        {
3549            let mut fragments = HashMap::new();
3550            fragments.insert(
3551                "index.html".to_string(),
3552                FragmentList {
3553                    fragments: vec![
3554                        WebUIFragment::raw("<input"),
3555                        WebUIFragment::attribute_boolean(
3556                            "checked",
3557                            ConditionExpr::identifier("checked"),
3558                        ),
3559                        WebUIFragment::raw(">"),
3560                    ],
3561                },
3562            );
3563            let protocol = WebUIProtocol::new(fragments);
3564            let state = test_json!({"checked": {}});
3565            let mut writer = TestWriter::new();
3566            handle(
3567                &protocol,
3568                &state,
3569                &RenderOptions::new("index.html", "/"),
3570                &mut writer,
3571            )
3572            .unwrap();
3573            // Empty object is falsy in this expression evaluator
3574            assert_eq!(writer.get_content(), "<input>");
3575        }
3576        // checked: "false" (string "false" is truthy)
3577        {
3578            let mut fragments = HashMap::new();
3579            fragments.insert(
3580                "index.html".to_string(),
3581                FragmentList {
3582                    fragments: vec![
3583                        WebUIFragment::raw("<input"),
3584                        WebUIFragment::attribute_boolean(
3585                            "checked",
3586                            ConditionExpr::identifier("checked"),
3587                        ),
3588                        WebUIFragment::raw(">"),
3589                    ],
3590                },
3591            );
3592            let protocol = WebUIProtocol::new(fragments);
3593            let state = test_json!({"checked": "false"});
3594            let mut writer = TestWriter::new();
3595            handle(
3596                &protocol,
3597                &state,
3598                &RenderOptions::new("index.html", "/"),
3599                &mut writer,
3600            )
3601            .unwrap();
3602            assert_eq!(writer.get_content(), "<input checked>");
3603        }
3604    }
3605
3606    #[test]
3607    fn test_boolean_attr_falsy_values() {
3608        // checked: 0
3609        {
3610            let mut fragments = HashMap::new();
3611            fragments.insert(
3612                "index.html".to_string(),
3613                FragmentList {
3614                    fragments: vec![
3615                        WebUIFragment::raw("<input"),
3616                        WebUIFragment::attribute_boolean(
3617                            "checked",
3618                            ConditionExpr::identifier("checked"),
3619                        ),
3620                        WebUIFragment::raw(">"),
3621                    ],
3622                },
3623            );
3624            let protocol = WebUIProtocol::new(fragments);
3625            let state = test_json!({"checked": 0});
3626            let mut writer = TestWriter::new();
3627            handle(
3628                &protocol,
3629                &state,
3630                &RenderOptions::new("index.html", "/"),
3631                &mut writer,
3632            )
3633            .unwrap();
3634            assert_eq!(writer.get_content(), "<input>");
3635        }
3636        // checked: ""
3637        {
3638            let mut fragments = HashMap::new();
3639            fragments.insert(
3640                "index.html".to_string(),
3641                FragmentList {
3642                    fragments: vec![
3643                        WebUIFragment::raw("<input"),
3644                        WebUIFragment::attribute_boolean(
3645                            "checked",
3646                            ConditionExpr::identifier("checked"),
3647                        ),
3648                        WebUIFragment::raw(">"),
3649                    ],
3650                },
3651            );
3652            let protocol = WebUIProtocol::new(fragments);
3653            let state = test_json!({"checked": ""});
3654            let mut writer = TestWriter::new();
3655            handle(
3656                &protocol,
3657                &state,
3658                &RenderOptions::new("index.html", "/"),
3659                &mut writer,
3660            )
3661            .unwrap();
3662            assert_eq!(writer.get_content(), "<input>");
3663        }
3664        // checked: false
3665        {
3666            let mut fragments = HashMap::new();
3667            fragments.insert(
3668                "index.html".to_string(),
3669                FragmentList {
3670                    fragments: vec![
3671                        WebUIFragment::raw("<input"),
3672                        WebUIFragment::attribute_boolean(
3673                            "checked",
3674                            ConditionExpr::identifier("checked"),
3675                        ),
3676                        WebUIFragment::raw(">"),
3677                    ],
3678                },
3679            );
3680            let protocol = WebUIProtocol::new(fragments);
3681            let state = test_json!({"checked": false});
3682            let mut writer = TestWriter::new();
3683            handle(
3684                &protocol,
3685                &state,
3686                &RenderOptions::new("index.html", "/"),
3687                &mut writer,
3688            )
3689            .unwrap();
3690            assert_eq!(writer.get_content(), "<input>");
3691        }
3692        // no checked key at all
3693        {
3694            let mut fragments = HashMap::new();
3695            fragments.insert(
3696                "index.html".to_string(),
3697                FragmentList {
3698                    fragments: vec![
3699                        WebUIFragment::raw("<input"),
3700                        WebUIFragment::attribute_boolean(
3701                            "checked",
3702                            ConditionExpr::identifier("checked"),
3703                        ),
3704                        WebUIFragment::raw(">"),
3705                    ],
3706                },
3707            );
3708            let protocol = WebUIProtocol::new(fragments);
3709            let state = test_json!({});
3710            let mut writer = TestWriter::new();
3711            handle(
3712                &protocol,
3713                &state,
3714                &RenderOptions::new("index.html", "/"),
3715                &mut writer,
3716            )
3717            .unwrap();
3718            assert_eq!(writer.get_content(), "<input>");
3719        }
3720    }
3721
3722    #[test]
3723    fn test_boolean_attr_expression_true() {
3724        let mut fragments = HashMap::new();
3725        fragments.insert(
3726            "index.html".to_string(),
3727            FragmentList {
3728                fragments: vec![
3729                    WebUIFragment::raw("<button"),
3730                    WebUIFragment::attribute_boolean(
3731                        "disabled",
3732                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
3733                    ),
3734                    WebUIFragment::raw(">Click</button>"),
3735                ],
3736            },
3737        );
3738        let protocol = WebUIProtocol::new(fragments);
3739        let state = test_json!({"itemCount": 5});
3740        let mut writer = TestWriter::new();
3741        handle(
3742            &protocol,
3743            &state,
3744            &RenderOptions::new("index.html", "/"),
3745            &mut writer,
3746        )
3747        .unwrap();
3748        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
3749    }
3750
3751    #[test]
3752    fn test_boolean_attr_expression_false() {
3753        let mut fragments = HashMap::new();
3754        fragments.insert(
3755            "index.html".to_string(),
3756            FragmentList {
3757                fragments: vec![
3758                    WebUIFragment::raw("<button"),
3759                    WebUIFragment::attribute_boolean(
3760                        "disabled",
3761                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
3762                    ),
3763                    WebUIFragment::raw(">Click</button>"),
3764                ],
3765            },
3766        );
3767        let protocol = WebUIProtocol::new(fragments);
3768        let state = test_json!({"itemCount": 3});
3769        let mut writer = TestWriter::new();
3770        handle(
3771            &protocol,
3772            &state,
3773            &RenderOptions::new("index.html", "/"),
3774            &mut writer,
3775        )
3776        .unwrap();
3777        assert_eq!(writer.get_content(), "<button>Click</button>");
3778    }
3779
3780    // ── GROUP 6: Mixed Attributes ─────────────────────────────────────
3781
3782    #[test]
3783    fn test_nested_component_attr_capture() {
3784        let mut fragments = HashMap::new();
3785        fragments.insert(
3786            "index.html".to_string(),
3787            FragmentList {
3788                fragments: vec![
3789                    WebUIFragment::raw("<parent-component"),
3790                    WebUIFragment {
3791                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3792                            WebUIFragmentAttribute {
3793                                name: "title".into(),
3794                                template: "parent-title".into(),
3795                                attr_start: true,
3796                                ..Default::default()
3797                            },
3798                        )),
3799                    },
3800                    WebUIFragment::raw(">"),
3801                    WebUIFragment::component("parent-component"),
3802                    WebUIFragment::raw("</parent-component>"),
3803                ],
3804            },
3805        );
3806        fragments.insert(
3807            "parent-title".to_string(),
3808            FragmentList {
3809                fragments: vec![
3810                    WebUIFragment::raw("Hello "),
3811                    WebUIFragment::signal("who", false),
3812                ],
3813            },
3814        );
3815        fragments.insert(
3816            "parent-component".to_string(),
3817            FragmentList {
3818                fragments: vec![
3819                    WebUIFragment::raw("<child-component"),
3820                    WebUIFragment {
3821                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3822                            WebUIFragmentAttribute {
3823                                name: "title".into(),
3824                                template: "child-title".into(),
3825                                attr_start: true,
3826                                ..Default::default()
3827                            },
3828                        )),
3829                    },
3830                    WebUIFragment::raw(">"),
3831                    WebUIFragment::component("child-component"),
3832                    WebUIFragment::raw("</child-component>"),
3833                ],
3834            },
3835        );
3836        fragments.insert(
3837            "child-title".to_string(),
3838            FragmentList {
3839                fragments: vec![
3840                    WebUIFragment::raw("Child of "),
3841                    WebUIFragment::signal("title", false),
3842                ],
3843            },
3844        );
3845        fragments.insert(
3846            "child-component".to_string(),
3847            FragmentList {
3848                fragments: vec![
3849                    WebUIFragment::raw("<span>"),
3850                    WebUIFragment::signal("title", false),
3851                    WebUIFragment::raw("</span>"),
3852                ],
3853            },
3854        );
3855        let protocol = WebUIProtocol::new(fragments);
3856        let state = test_json!({"who": "<world>"});
3857        let mut writer = TestWriter::new();
3858        handle(
3859            &protocol,
3860            &state,
3861            &RenderOptions::new("index.html", "/"),
3862            &mut writer,
3863        )
3864        .unwrap();
3865        assert_eq!(
3866            writer.get_content(),
3867            "<parent-component title=\"Hello &lt;world&gt;\"><child-component title=\"Child of Hello &lt;world&gt;\"><span>Child of Hello &lt;world&gt;</span></child-component></parent-component>"
3868        );
3869    }
3870
3871    #[test]
3872    fn test_grandchild_attr_propagation() {
3873        let mut fragments = HashMap::new();
3874        fragments.insert(
3875            "index.html".to_string(),
3876            FragmentList {
3877                fragments: vec![
3878                    WebUIFragment::raw("<parent-component"),
3879                    WebUIFragment {
3880                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3881                            WebUIFragmentAttribute {
3882                                name: "title".into(),
3883                                template: "p-title".into(),
3884                                attr_start: true,
3885                                ..Default::default()
3886                            },
3887                        )),
3888                    },
3889                    WebUIFragment::raw(">"),
3890                    WebUIFragment::component("parent-component"),
3891                    WebUIFragment::raw("</parent-component>"),
3892                ],
3893            },
3894        );
3895        fragments.insert(
3896            "p-title".to_string(),
3897            FragmentList {
3898                fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
3899            },
3900        );
3901        fragments.insert(
3902            "parent-component".to_string(),
3903            FragmentList {
3904                fragments: vec![
3905                    WebUIFragment::raw("<child-component"),
3906                    WebUIFragment {
3907                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3908                            WebUIFragmentAttribute {
3909                                name: "title".into(),
3910                                template: "c-title".into(),
3911                                attr_start: true,
3912                                ..Default::default()
3913                            },
3914                        )),
3915                    },
3916                    WebUIFragment::raw(">"),
3917                    WebUIFragment::component("child-component"),
3918                    WebUIFragment::raw("</child-component>"),
3919                ],
3920            },
3921        );
3922        fragments.insert(
3923            "c-title".to_string(),
3924            FragmentList {
3925                fragments: vec![
3926                    WebUIFragment::raw("C("),
3927                    WebUIFragment::signal("title", false),
3928                    WebUIFragment::raw(")-"),
3929                    WebUIFragment::signal("cExtra", false),
3930                ],
3931            },
3932        );
3933        fragments.insert(
3934            "child-component".to_string(),
3935            FragmentList {
3936                fragments: vec![
3937                    WebUIFragment::raw("<grandchild-component"),
3938                    WebUIFragment {
3939                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3940                            WebUIFragmentAttribute {
3941                                name: "title".into(),
3942                                value: "title".into(),
3943                                attr_start: true,
3944                                ..Default::default()
3945                            },
3946                        )),
3947                    },
3948                    WebUIFragment::raw(">"),
3949                    WebUIFragment::component("grandchild-component"),
3950                    WebUIFragment::raw("</grandchild-component>"),
3951                ],
3952            },
3953        );
3954        fragments.insert(
3955            "grandchild-component".to_string(),
3956            FragmentList {
3957                fragments: vec![
3958                    WebUIFragment::raw("<span>"),
3959                    WebUIFragment::signal("title", false),
3960                    WebUIFragment::raw("</span>"),
3961                ],
3962            },
3963        );
3964        let protocol = WebUIProtocol::new(fragments);
3965        let state = test_json!({"p": "<p>", "cExtra": "x&y"});
3966        let mut writer = TestWriter::new();
3967        handle(
3968            &protocol,
3969            &state,
3970            &RenderOptions::new("index.html", "/"),
3971            &mut writer,
3972        )
3973        .unwrap();
3974        assert_eq!(
3975            writer.get_content(),
3976            "<parent-component title=\"P:&lt;p&gt;\"><child-component title=\"C(P:&lt;p&gt;)-x&amp;y\"><grandchild-component title=\"C(P:&lt;p&gt;)-x&amp;y\"><span>C(P:&lt;p&gt;)-x&amp;y</span></grandchild-component></child-component></parent-component>"
3977        );
3978    }
3979
3980    #[test]
3981    fn test_for_loop_component_attr() {
3982        let mut fragments = HashMap::new();
3983        fragments.insert(
3984            "index.html".to_string(),
3985            FragmentList {
3986                fragments: vec![
3987                    WebUIFragment::raw("<parent-component"),
3988                    WebUIFragment {
3989                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3990                            WebUIFragmentAttribute {
3991                                name: "title".into(),
3992                                template: "parent-title-loop".into(),
3993                                attr_start: true,
3994                                ..Default::default()
3995                            },
3996                        )),
3997                    },
3998                    WebUIFragment::raw(">"),
3999                    WebUIFragment::component("parent-component"),
4000                    WebUIFragment::raw("</parent-component>"),
4001                ],
4002            },
4003        );
4004        fragments.insert(
4005            "parent-title-loop".to_string(),
4006            FragmentList {
4007                fragments: vec![
4008                    WebUIFragment::raw("Parent:"),
4009                    WebUIFragment::signal("who", false),
4010                ],
4011            },
4012        );
4013        fragments.insert(
4014            "parent-component".to_string(),
4015            FragmentList {
4016                fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
4017            },
4018        );
4019        fragments.insert(
4020            "child-loop".to_string(),
4021            FragmentList {
4022                fragments: vec![
4023                    WebUIFragment::raw("<child-component"),
4024                    WebUIFragment {
4025                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4026                            WebUIFragmentAttribute {
4027                                name: "title".into(),
4028                                template: "child-title-loop".into(),
4029                                attr_start: true,
4030                                ..Default::default()
4031                            },
4032                        )),
4033                    },
4034                    WebUIFragment::raw(">"),
4035                    WebUIFragment::component("child-component"),
4036                    WebUIFragment::raw("</child-component>"),
4037                ],
4038            },
4039        );
4040        fragments.insert(
4041            "child-title-loop".to_string(),
4042            FragmentList {
4043                fragments: vec![
4044                    WebUIFragment::raw("Hi "),
4045                    WebUIFragment::signal("item.name", false),
4046                    WebUIFragment::raw(" / "),
4047                    WebUIFragment::signal("title", false),
4048                ],
4049            },
4050        );
4051        fragments.insert(
4052            "child-component".to_string(),
4053            FragmentList {
4054                fragments: vec![
4055                    WebUIFragment::raw("<span>"),
4056                    WebUIFragment::signal("title", false),
4057                    WebUIFragment::raw("</span>"),
4058                ],
4059            },
4060        );
4061        let protocol = WebUIProtocol::new(fragments);
4062        let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
4063        let mut writer = TestWriter::new();
4064        handle(
4065            &protocol,
4066            &state,
4067            &RenderOptions::new("index.html", "/"),
4068            &mut writer,
4069        )
4070        .unwrap();
4071        assert_eq!(
4072            writer.get_content(),
4073            "<parent-component title=\"Parent:Bob\"><child-component title=\"Hi A&lt;1&gt; &#x2F; Parent:Bob\"><span>Hi A&lt;1&gt; &#x2F; Parent:Bob</span></child-component><child-component title=\"Hi B&amp;2 &#x2F; Parent:Bob\"><span>Hi B&amp;2 &#x2F; Parent:Bob</span></child-component></parent-component>"
4074        );
4075    }
4076
4077    #[test]
4078    fn test_multiple_template_attrs() {
4079        let mut fragments = HashMap::new();
4080        fragments.insert(
4081            "index.html".to_string(),
4082            FragmentList {
4083                fragments: vec![
4084                    WebUIFragment::raw("<my-component"),
4085                    WebUIFragment {
4086                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4087                            WebUIFragmentAttribute {
4088                                name: "title".into(),
4089                                template: "attr-title".into(),
4090                                attr_start: true,
4091                                ..Default::default()
4092                            },
4093                        )),
4094                    },
4095                    WebUIFragment {
4096                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4097                            WebUIFragmentAttribute {
4098                                name: "data-title".into(),
4099                                template: "attr-data-title".into(),
4100                                attr_start: false,
4101                                ..Default::default()
4102                            },
4103                        )),
4104                    },
4105                    WebUIFragment {
4106                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4107                            WebUIFragmentAttribute {
4108                                name: "aria-label".into(),
4109                                template: "attr-aria-label".into(),
4110                                attr_start: false,
4111                                ..Default::default()
4112                            },
4113                        )),
4114                    },
4115                    WebUIFragment::raw(">"),
4116                    WebUIFragment::component("my-component"),
4117                    WebUIFragment::raw("</my-component>"),
4118                ],
4119            },
4120        );
4121        fragments.insert(
4122            "attr-title".to_string(),
4123            FragmentList {
4124                fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
4125            },
4126        );
4127        fragments.insert(
4128            "attr-data-title".to_string(),
4129            FragmentList {
4130                fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
4131            },
4132        );
4133        fragments.insert(
4134            "attr-aria-label".to_string(),
4135            FragmentList {
4136                fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
4137            },
4138        );
4139        fragments.insert(
4140            "my-component".to_string(),
4141            FragmentList {
4142                fragments: vec![
4143                    WebUIFragment::raw("<span>"),
4144                    WebUIFragment::signal("title", false),
4145                    WebUIFragment::raw("|"),
4146                    WebUIFragment::signal("dataTitle", false),
4147                    WebUIFragment::raw("|"),
4148                    WebUIFragment::signal("ariaLabel", false),
4149                    WebUIFragment::raw("</span>"),
4150                ],
4151            },
4152        );
4153        let protocol = WebUIProtocol::new(fragments);
4154        let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
4155        let mut writer = TestWriter::new();
4156        handle(
4157            &protocol,
4158            &state,
4159            &RenderOptions::new("index.html", "/"),
4160            &mut writer,
4161        )
4162        .unwrap();
4163        assert_eq!(
4164            writer.get_content(),
4165            "<my-component title=\"T:&lt;t&amp;1&gt;\" data-title=\"D:d&lt;2&gt;\" aria-label=\"A:a&amp;3\"><span>T:&lt;t&amp;1&gt;|D:d&lt;2&gt;|A:a&amp;3</span></my-component>"
4166        );
4167    }
4168
4169    #[test]
4170    fn test_attr_priority_over_global() {
4171        let mut fragments = HashMap::new();
4172        fragments.insert(
4173            "index.html".to_string(),
4174            FragmentList {
4175                fragments: vec![
4176                    WebUIFragment::raw("<my-component"),
4177                    WebUIFragment {
4178                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4179                            WebUIFragmentAttribute {
4180                                name: "title".into(),
4181                                value: "Attribute Title".into(),
4182                                raw_value: true,
4183                                attr_start: true,
4184                                ..Default::default()
4185                            },
4186                        )),
4187                    },
4188                    WebUIFragment::raw(">"),
4189                    WebUIFragment::component("my-component"),
4190                    WebUIFragment::raw("</my-component>"),
4191                ],
4192            },
4193        );
4194        fragments.insert(
4195            "my-component".to_string(),
4196            FragmentList {
4197                fragments: vec![
4198                    WebUIFragment::raw("<span>"),
4199                    WebUIFragment::signal("title", false),
4200                    WebUIFragment::raw("</span>"),
4201                ],
4202            },
4203        );
4204        let protocol = WebUIProtocol::new(fragments);
4205        let state = test_json!({"title": "Global Title"});
4206        let mut writer = TestWriter::new();
4207        handle(
4208            &protocol,
4209            &state,
4210            &RenderOptions::new("index.html", "/"),
4211            &mut writer,
4212        )
4213        .unwrap();
4214        assert_eq!(
4215            writer.get_content(),
4216            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
4217        );
4218    }
4219
4220    #[test]
4221    fn test_attr_priority_over_local_and_global() {
4222        let mut fragments = HashMap::new();
4223        fragments.insert(
4224            "index.html".to_string(),
4225            FragmentList {
4226                fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
4227            },
4228        );
4229        fragments.insert(
4230            "loop".to_string(),
4231            FragmentList {
4232                fragments: vec![
4233                    WebUIFragment::raw("<my-component"),
4234                    WebUIFragment {
4235                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4236                            WebUIFragmentAttribute {
4237                                name: "title".into(),
4238                                value: "Attribute Title".into(),
4239                                raw_value: true,
4240                                attr_start: true,
4241                                ..Default::default()
4242                            },
4243                        )),
4244                    },
4245                    WebUIFragment::raw(">"),
4246                    WebUIFragment::component("my-component"),
4247                    WebUIFragment::raw("</my-component>"),
4248                ],
4249            },
4250        );
4251        fragments.insert(
4252            "my-component".to_string(),
4253            FragmentList {
4254                fragments: vec![
4255                    WebUIFragment::raw("<span>"),
4256                    WebUIFragment::signal("title", false),
4257                    WebUIFragment::raw("</span>"),
4258                ],
4259            },
4260        );
4261        let protocol = WebUIProtocol::new(fragments);
4262        let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
4263        let mut writer = TestWriter::new();
4264        handle(
4265            &protocol,
4266            &state,
4267            &RenderOptions::new("index.html", "/"),
4268            &mut writer,
4269        )
4270        .unwrap();
4271        assert_eq!(
4272            writer.get_content(),
4273            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
4274        );
4275    }
4276
4277    #[test]
4278    fn test_boolean_attr_first_component_attr() {
4279        let mut fragments = HashMap::new();
4280        fragments.insert(
4281            "index.html".to_string(),
4282            FragmentList {
4283                fragments: vec![
4284                    WebUIFragment::raw("<my-component"),
4285                    WebUIFragment {
4286                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4287                            WebUIFragmentAttribute {
4288                                name: "disabled".into(),
4289                                attr_start: true,
4290                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4291                                ..Default::default()
4292                            },
4293                        )),
4294                    },
4295                    WebUIFragment {
4296                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4297                            WebUIFragmentAttribute {
4298                                name: "label".into(),
4299                                value: "Component Label".into(),
4300                                raw_value: true,
4301                                attr_start: false,
4302                                ..Default::default()
4303                            },
4304                        )),
4305                    },
4306                    WebUIFragment::raw(">"),
4307                    WebUIFragment::component("my-component"),
4308                    WebUIFragment::raw("</my-component>"),
4309                ],
4310            },
4311        );
4312        fragments.insert(
4313            "my-component".to_string(),
4314            FragmentList {
4315                fragments: vec![
4316                    WebUIFragment::if_cond(
4317                        ConditionExpr::identifier("disabled"),
4318                        "disabledTemplate",
4319                    ),
4320                    WebUIFragment::raw("<span>"),
4321                    WebUIFragment::signal("label", false),
4322                    WebUIFragment::raw("</span>"),
4323                ],
4324            },
4325        );
4326        fragments.insert(
4327            "disabledTemplate".to_string(),
4328            FragmentList {
4329                fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
4330            },
4331        );
4332        let protocol = WebUIProtocol::new(fragments);
4333        let state = test_json!({"isDisabled": true});
4334        let mut writer = TestWriter::new();
4335        handle(
4336            &protocol,
4337            &state,
4338            &RenderOptions::new("index.html", "/"),
4339            &mut writer,
4340        )
4341        .unwrap();
4342        assert_eq!(
4343            writer.get_content(),
4344            "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
4345        );
4346    }
4347
4348    #[test]
4349    fn test_hyphenated_attr_camelcase() {
4350        let mut fragments = HashMap::new();
4351        fragments.insert(
4352            "index.html".to_string(),
4353            FragmentList {
4354                fragments: vec![
4355                    WebUIFragment::raw("<my-component"),
4356                    WebUIFragment {
4357                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4358                            WebUIFragmentAttribute {
4359                                name: "key-hyphen".into(),
4360                                value: "Local Value".into(),
4361                                raw_value: true,
4362                                attr_start: true,
4363                                ..Default::default()
4364                            },
4365                        )),
4366                    },
4367                    WebUIFragment::raw(">"),
4368                    WebUIFragment::component("my-component"),
4369                    WebUIFragment::raw("</my-component>"),
4370                ],
4371            },
4372        );
4373        fragments.insert(
4374            "my-component".to_string(),
4375            FragmentList {
4376                fragments: vec![
4377                    WebUIFragment::raw("<span>"),
4378                    WebUIFragment::signal("keyHyphen", false),
4379                    WebUIFragment::raw("</span>"),
4380                ],
4381            },
4382        );
4383        let protocol = WebUIProtocol::new(fragments);
4384        let state = test_json!({"keyHyphen": "Global Value"});
4385        let mut writer = TestWriter::new();
4386        handle(
4387            &protocol,
4388            &state,
4389            &RenderOptions::new("index.html", "/"),
4390            &mut writer,
4391        )
4392        .unwrap();
4393        assert_eq!(
4394            writer.get_content(),
4395            "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
4396        );
4397    }
4398
4399    #[test]
4400    fn test_skipped_component_attrs() {
4401        // Skipped attributes: class, style, role, data-*, aria-*
4402        // Plus framework-specific prefixes/names that the parser marks with attr_skip.
4403        // These render on the HTML element but are NOT passed into component attribute state.
4404        let mut fragments = HashMap::new();
4405        fragments.insert(
4406            "index.html".to_string(),
4407            FragmentList {
4408                fragments: vec![
4409                    WebUIFragment::raw("<test-component"),
4410                    // Skipped: class
4411                    WebUIFragment {
4412                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4413                            WebUIFragmentAttribute {
4414                                name: "class".into(),
4415                                value: "skippedClass".into(),
4416                                attr_start: true,
4417                                attr_skip: true,
4418                                ..Default::default()
4419                            },
4420                        )),
4421                    },
4422                    // Skipped: style
4423                    WebUIFragment {
4424                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4425                            WebUIFragmentAttribute {
4426                                name: "style".into(),
4427                                value: "skippedStyle".into(),
4428                                attr_skip: true,
4429                                ..Default::default()
4430                            },
4431                        )),
4432                    },
4433                    // Skipped: role
4434                    WebUIFragment {
4435                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4436                            WebUIFragmentAttribute {
4437                                name: "role".into(),
4438                                value: "skippedRole".into(),
4439                                attr_skip: true,
4440                                ..Default::default()
4441                            },
4442                        )),
4443                    },
4444                    // Skipped: data-testid (data-* prefix)
4445                    WebUIFragment {
4446                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4447                            WebUIFragmentAttribute {
4448                                name: "data-testid".into(),
4449                                value: "skippedDataTestid".into(),
4450                                attr_skip: true,
4451                                ..Default::default()
4452                            },
4453                        )),
4454                    },
4455                    // Skipped: aria-label (aria-* prefix)
4456                    WebUIFragment {
4457                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4458                            WebUIFragmentAttribute {
4459                                name: "aria-label".into(),
4460                                value: "skippedAriaLabel".into(),
4461                                attr_skip: true,
4462                                ..Default::default()
4463                            },
4464                        )),
4465                    },
4466                    // NOT skipped: title
4467                    WebUIFragment {
4468                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4469                            WebUIFragmentAttribute {
4470                                name: "title".into(),
4471                                value: "title".into(),
4472                                ..Default::default()
4473                            },
4474                        )),
4475                    },
4476                    WebUIFragment::raw(">"),
4477                    WebUIFragment::component("test-component"),
4478                    WebUIFragment::raw("</test-component>"),
4479                ],
4480            },
4481        );
4482        fragments.insert(
4483            "test-component".to_string(),
4484            FragmentList {
4485                fragments: vec![
4486                    WebUIFragment::raw("<span>"),
4487                    WebUIFragment::signal("title", false),
4488                    WebUIFragment::raw("-"),
4489                    WebUIFragment::signal("class", false),
4490                    WebUIFragment::raw("-"),
4491                    WebUIFragment::signal("style", false),
4492                    WebUIFragment::raw("-"),
4493                    WebUIFragment::signal("role", false),
4494                    WebUIFragment::raw("-"),
4495                    WebUIFragment::signal("dataTestid", false),
4496                    WebUIFragment::raw("-"),
4497                    WebUIFragment::signal("ariaLabel", false),
4498                    WebUIFragment::raw("</span>"),
4499                ],
4500            },
4501        );
4502        let protocol = WebUIProtocol::new(fragments);
4503        let state = test_json!({
4504            "title": "Hello",
4505            "skippedClass": "my-class",
4506            "skippedStyle": "color:red",
4507            "skippedRole": "button",
4508            "skippedDataTestid": "test-id",
4509            "skippedAriaLabel": "label-text"
4510        });
4511        let mut writer = TestWriter::new();
4512        handle(
4513            &protocol,
4514            &state,
4515            &RenderOptions::new("index.html", "/"),
4516            &mut writer,
4517        )
4518        .unwrap();
4519        // Skipped attrs render on the element but their values are NOT accessible inside the component.
4520        // The component's signals for skipped attrs resolve to empty strings.
4521        // Only "title" (non-skipped) is accessible.
4522        assert_eq!(
4523            writer.get_content(),
4524            "<test-component class=\"my-class\" style=\"color:red\" role=\"button\" data-testid=\"test-id\" aria-label=\"label-text\" title=\"Hello\"><span>Hello-----</span></test-component>"
4525        );
4526    }
4527
4528    // ── GROUP 7: Attribute Inheritance ─────────────────────────────────
4529
4530    #[test]
4531    fn test_attr_inherit_parent_to_child() {
4532        let mut fragments = HashMap::new();
4533        fragments.insert(
4534            "index.html".to_string(),
4535            FragmentList {
4536                fragments: vec![
4537                    WebUIFragment::raw("<parent-component"),
4538                    WebUIFragment {
4539                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4540                            WebUIFragmentAttribute {
4541                                name: "title".into(),
4542                                value: "Parent Title".into(),
4543                                raw_value: true,
4544                                attr_start: true,
4545                                ..Default::default()
4546                            },
4547                        )),
4548                    },
4549                    WebUIFragment::raw(">"),
4550                    WebUIFragment::component("parent-component"),
4551                    WebUIFragment::raw("</parent-component>"),
4552                ],
4553            },
4554        );
4555        fragments.insert(
4556            "parent-component".to_string(),
4557            FragmentList {
4558                fragments: vec![
4559                    WebUIFragment::raw("<h1>"),
4560                    WebUIFragment::signal("title", false),
4561                    WebUIFragment::raw("</h1><child-component"),
4562                    WebUIFragment {
4563                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4564                            WebUIFragmentAttribute {
4565                                name: "title".into(),
4566                                value: "title".into(),
4567                                attr_start: true,
4568                                ..Default::default()
4569                            },
4570                        )),
4571                    },
4572                    WebUIFragment::raw(">"),
4573                    WebUIFragment::component("child-component"),
4574                    WebUIFragment::raw("</child-component>"),
4575                ],
4576            },
4577        );
4578        fragments.insert(
4579            "child-component".to_string(),
4580            FragmentList {
4581                fragments: vec![
4582                    WebUIFragment::raw("<h2>"),
4583                    WebUIFragment::signal("title", false),
4584                    WebUIFragment::raw("</h2>"),
4585                ],
4586            },
4587        );
4588        let protocol = WebUIProtocol::new(fragments);
4589        let state = test_json!({});
4590        let mut writer = TestWriter::new();
4591        handle(
4592            &protocol,
4593            &state,
4594            &RenderOptions::new("index.html", "/"),
4595            &mut writer,
4596        )
4597        .unwrap();
4598        assert_eq!(
4599            writer.get_content(),
4600            "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
4601        );
4602    }
4603
4604    #[test]
4605    fn test_attr_inherit_deep() {
4606        let mut fragments = HashMap::new();
4607        fragments.insert(
4608            "index.html".to_string(),
4609            FragmentList {
4610                fragments: vec![
4611                    WebUIFragment::raw("<parent-component"),
4612                    WebUIFragment {
4613                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4614                            WebUIFragmentAttribute {
4615                                name: "title".into(),
4616                                value: "Parent Title".into(),
4617                                raw_value: true,
4618                                attr_start: true,
4619                                ..Default::default()
4620                            },
4621                        )),
4622                    },
4623                    WebUIFragment::raw(">"),
4624                    WebUIFragment::component("parent-component"),
4625                    WebUIFragment::raw("</parent-component>"),
4626                ],
4627            },
4628        );
4629        fragments.insert(
4630            "parent-component".to_string(),
4631            FragmentList {
4632                fragments: vec![
4633                    WebUIFragment::raw("<child-component"),
4634                    WebUIFragment {
4635                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4636                            WebUIFragmentAttribute {
4637                                name: "title".into(),
4638                                value: "Child Title".into(),
4639                                raw_value: true,
4640                                attr_start: true,
4641                                ..Default::default()
4642                            },
4643                        )),
4644                    },
4645                    WebUIFragment::raw(">"),
4646                    WebUIFragment::component("child-component"),
4647                    WebUIFragment::raw("</child-component>"),
4648                ],
4649            },
4650        );
4651        fragments.insert(
4652            "child-component".to_string(),
4653            FragmentList {
4654                fragments: vec![
4655                    WebUIFragment::raw("<grandchild-component"),
4656                    WebUIFragment {
4657                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4658                            WebUIFragmentAttribute {
4659                                name: "title".into(),
4660                                value: "title".into(),
4661                                attr_start: true,
4662                                ..Default::default()
4663                            },
4664                        )),
4665                    },
4666                    WebUIFragment::raw(">"),
4667                    WebUIFragment::component("grandchild-component"),
4668                    WebUIFragment::raw("</grandchild-component>"),
4669                ],
4670            },
4671        );
4672        fragments.insert(
4673            "grandchild-component".to_string(),
4674            FragmentList {
4675                fragments: vec![
4676                    WebUIFragment::raw("<h3>"),
4677                    WebUIFragment::signal("title", false),
4678                    WebUIFragment::raw("</h3>"),
4679                ],
4680            },
4681        );
4682        let protocol = WebUIProtocol::new(fragments);
4683        let state = test_json!({});
4684        let mut writer = TestWriter::new();
4685        handle(
4686            &protocol,
4687            &state,
4688            &RenderOptions::new("index.html", "/"),
4689            &mut writer,
4690        )
4691        .unwrap();
4692        assert_eq!(
4693            writer.get_content(),
4694            "<parent-component title=\"Parent Title\"><child-component title=\"Child Title\"><grandchild-component title=\"Child Title\"><h3>Child Title</h3></grandchild-component></child-component></parent-component>"
4695        );
4696    }
4697
4698    #[test]
4699    fn test_complex_attr_access() {
4700        let mut fragments = HashMap::new();
4701        fragments.insert(
4702            "index.html".to_string(),
4703            FragmentList {
4704                fragments: vec![
4705                    WebUIFragment::raw("<my-component"),
4706                    WebUIFragment {
4707                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4708                            WebUIFragmentAttribute {
4709                                name: ":item".into(),
4710                                value: "complexItem".into(),
4711                                attr_start: true,
4712                                complex: true,
4713                                ..Default::default()
4714                            },
4715                        )),
4716                    },
4717                    WebUIFragment::raw(">"),
4718                    WebUIFragment::component("my-component"),
4719                    WebUIFragment::raw("</my-component>"),
4720                ],
4721            },
4722        );
4723        fragments.insert(
4724            "my-component".to_string(),
4725            FragmentList {
4726                fragments: vec![
4727                    WebUIFragment::raw("<span>"),
4728                    WebUIFragment::signal("item.foo", false),
4729                    WebUIFragment::raw("</span><p>"),
4730                    WebUIFragment::signal("item.bar", false),
4731                    WebUIFragment::raw("</p>"),
4732                ],
4733            },
4734        );
4735        let protocol = WebUIProtocol::new(fragments);
4736        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
4737        let mut writer = TestWriter::new();
4738        handle(
4739            &protocol,
4740            &state,
4741            &RenderOptions::new("index.html", "/"),
4742            &mut writer,
4743        )
4744        .unwrap();
4745        assert_eq!(
4746            writer.get_content(),
4747            "<my-component><span>1</span><p>true</p></my-component>"
4748        );
4749    }
4750
4751    #[test]
4752    fn test_complex_attr_for_loop() {
4753        let mut fragments = HashMap::new();
4754        fragments.insert(
4755            "index.html".to_string(),
4756            FragmentList {
4757                fragments: vec![WebUIFragment::for_loop(
4758                    "item",
4759                    "list.items",
4760                    "listTemplate",
4761                )],
4762            },
4763        );
4764        fragments.insert(
4765            "listTemplate".to_string(),
4766            FragmentList {
4767                fragments: vec![
4768                    WebUIFragment {
4769                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4770                            WebUIFragmentAttribute {
4771                                name: ":item".into(),
4772                                value: "item".into(),
4773                                attr_start: true,
4774                                complex: true,
4775                                ..Default::default()
4776                            },
4777                        )),
4778                    },
4779                    WebUIFragment::component("item_component"),
4780                ],
4781            },
4782        );
4783        fragments.insert(
4784            "item_component".to_string(),
4785            FragmentList {
4786                fragments: vec![
4787                    WebUIFragment::raw("<span>"),
4788                    WebUIFragment::signal("item.name", false),
4789                    WebUIFragment::raw("</span>"),
4790                ],
4791            },
4792        );
4793        let protocol = WebUIProtocol::new(fragments);
4794        let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
4795        let mut writer = TestWriter::new();
4796        handle(
4797            &protocol,
4798            &state,
4799            &RenderOptions::new("index.html", "/"),
4800            &mut writer,
4801        )
4802        .unwrap();
4803        assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
4804    }
4805
4806    #[test]
4807    fn test_complex_attr_nested_for() {
4808        let mut fragments = HashMap::new();
4809        fragments.insert(
4810            "index.html".to_string(),
4811            FragmentList {
4812                fragments: vec![WebUIFragment::for_loop(
4813                    "outer",
4814                    "data.outer",
4815                    "outerTemplate",
4816                )],
4817            },
4818        );
4819        fragments.insert(
4820            "outerTemplate".to_string(),
4821            FragmentList {
4822                fragments: vec![WebUIFragment::for_loop(
4823                    "middle",
4824                    "outer.middle",
4825                    "middleTemplate",
4826                )],
4827            },
4828        );
4829        fragments.insert(
4830            "middleTemplate".to_string(),
4831            FragmentList {
4832                fragments: vec![WebUIFragment::for_loop(
4833                    "inner",
4834                    "middle.inner",
4835                    "innerTemplate",
4836                )],
4837            },
4838        );
4839        fragments.insert(
4840            "innerTemplate".to_string(),
4841            FragmentList {
4842                fragments: vec![
4843                    WebUIFragment::raw("<card"),
4844                    WebUIFragment {
4845                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4846                            WebUIFragmentAttribute {
4847                                name: ":outer".into(),
4848                                value: "outer".into(),
4849                                attr_start: true,
4850                                complex: true,
4851                                ..Default::default()
4852                            },
4853                        )),
4854                    },
4855                    WebUIFragment {
4856                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4857                            WebUIFragmentAttribute {
4858                                name: ":middle".into(),
4859                                value: "middle".into(),
4860                                attr_start: false,
4861                                complex: true,
4862                                ..Default::default()
4863                            },
4864                        )),
4865                    },
4866                    WebUIFragment {
4867                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4868                            WebUIFragmentAttribute {
4869                                name: ":inner".into(),
4870                                value: "inner".into(),
4871                                attr_start: false,
4872                                complex: true,
4873                                ..Default::default()
4874                            },
4875                        )),
4876                    },
4877                    WebUIFragment::raw(">"),
4878                    WebUIFragment::component("card_component"),
4879                    WebUIFragment::raw("</card>"),
4880                ],
4881            },
4882        );
4883        fragments.insert(
4884            "card_component".to_string(),
4885            FragmentList {
4886                fragments: vec![
4887                    WebUIFragment::raw("<p>"),
4888                    WebUIFragment::signal("outer.label", false),
4889                    WebUIFragment::raw(" / "),
4890                    WebUIFragment::signal("middle.label", false),
4891                    WebUIFragment::raw(" / "),
4892                    WebUIFragment::signal("inner.label", false),
4893                    WebUIFragment::raw("</p>"),
4894                ],
4895            },
4896        );
4897        let protocol = WebUIProtocol::new(fragments);
4898        let state = test_json!({"data": {"outer": [
4899            {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
4900            {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
4901        ]}});
4902        let mut writer = TestWriter::new();
4903        handle(
4904            &protocol,
4905            &state,
4906            &RenderOptions::new("index.html", "/"),
4907            &mut writer,
4908        )
4909        .unwrap();
4910        assert_eq!(
4911            writer.get_content(),
4912            "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
4913        );
4914    }
4915
4916    // ── GROUP 8: Boolean Component State ──────────────────────────────
4917
4918    #[test]
4919    fn test_bool_component_state_true() {
4920        let mut fragments = HashMap::new();
4921        fragments.insert(
4922            "index.html".to_string(),
4923            FragmentList {
4924                fragments: vec![
4925                    WebUIFragment::raw("<my-component"),
4926                    WebUIFragment {
4927                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4928                            WebUIFragmentAttribute {
4929                                name: "disabled".into(),
4930                                attr_start: true,
4931                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4932                                ..Default::default()
4933                            },
4934                        )),
4935                    },
4936                    WebUIFragment::raw(">"),
4937                    WebUIFragment::component("my-component"),
4938                    WebUIFragment::raw("</my-component>"),
4939                ],
4940            },
4941        );
4942        fragments.insert(
4943            "my-component".to_string(),
4944            FragmentList {
4945                fragments: vec![
4946                    WebUIFragment::if_cond(
4947                        ConditionExpr::identifier("disabled"),
4948                        "disabledTemplate",
4949                    ),
4950                    WebUIFragment::if_cond(
4951                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
4952                        "enabledTemplate",
4953                    ),
4954                ],
4955            },
4956        );
4957        fragments.insert(
4958            "disabledTemplate".to_string(),
4959            FragmentList {
4960                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
4961            },
4962        );
4963        fragments.insert(
4964            "enabledTemplate".to_string(),
4965            FragmentList {
4966                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
4967            },
4968        );
4969        let protocol = WebUIProtocol::new(fragments);
4970        let state = test_json!({"isDisabled": true});
4971        let mut writer = TestWriter::new();
4972        handle(
4973            &protocol,
4974            &state,
4975            &RenderOptions::new("index.html", "/"),
4976            &mut writer,
4977        )
4978        .unwrap();
4979        assert_eq!(
4980            writer.get_content(),
4981            "<my-component disabled><span>Disabled</span></my-component>"
4982        );
4983    }
4984
4985    #[test]
4986    fn test_bool_component_state_false() {
4987        let mut fragments = HashMap::new();
4988        fragments.insert(
4989            "index.html".to_string(),
4990            FragmentList {
4991                fragments: vec![
4992                    WebUIFragment::raw("<my-component"),
4993                    WebUIFragment {
4994                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4995                            WebUIFragmentAttribute {
4996                                name: "disabled".into(),
4997                                attr_start: true,
4998                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4999                                ..Default::default()
5000                            },
5001                        )),
5002                    },
5003                    WebUIFragment::raw(">"),
5004                    WebUIFragment::component("my-component"),
5005                    WebUIFragment::raw("</my-component>"),
5006                ],
5007            },
5008        );
5009        fragments.insert(
5010            "my-component".to_string(),
5011            FragmentList {
5012                fragments: vec![
5013                    WebUIFragment::if_cond(
5014                        ConditionExpr::identifier("disabled"),
5015                        "disabledTemplate",
5016                    ),
5017                    WebUIFragment::if_cond(
5018                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
5019                        "enabledTemplate",
5020                    ),
5021                ],
5022            },
5023        );
5024        fragments.insert(
5025            "disabledTemplate".to_string(),
5026            FragmentList {
5027                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
5028            },
5029        );
5030        fragments.insert(
5031            "enabledTemplate".to_string(),
5032            FragmentList {
5033                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
5034            },
5035        );
5036        let protocol = WebUIProtocol::new(fragments);
5037        let state = test_json!({"isDisabled": false});
5038        let mut writer = TestWriter::new();
5039        handle(
5040            &protocol,
5041            &state,
5042            &RenderOptions::new("index.html", "/"),
5043            &mut writer,
5044        )
5045        .unwrap();
5046        assert_eq!(
5047            writer.get_content(),
5048            "<my-component><span>Enabled</span></my-component>"
5049        );
5050    }
5051
5052    #[test]
5053    fn test_bool_component_state_forward() {
5054        let mut fragments = HashMap::new();
5055        fragments.insert(
5056            "index.html".to_string(),
5057            FragmentList {
5058                fragments: vec![
5059                    WebUIFragment::raw("<parent-component"),
5060                    WebUIFragment {
5061                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5062                            WebUIFragmentAttribute {
5063                                name: "disabled".into(),
5064                                attr_start: true,
5065                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
5066                                ..Default::default()
5067                            },
5068                        )),
5069                    },
5070                    WebUIFragment::raw(">"),
5071                    WebUIFragment::component("parent-component"),
5072                    WebUIFragment::raw("</parent-component>"),
5073                ],
5074            },
5075        );
5076        fragments.insert(
5077            "parent-component".to_string(),
5078            FragmentList {
5079                fragments: vec![
5080                    WebUIFragment::if_cond(
5081                        ConditionExpr::identifier("disabled"),
5082                        "parentDisabledTemplate",
5083                    ),
5084                    WebUIFragment::raw("<child-component"),
5085                    WebUIFragment {
5086                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5087                            WebUIFragmentAttribute {
5088                                name: "disabled".into(),
5089                                attr_start: true,
5090                                condition_tree: Some(ConditionExpr::identifier("disabled")),
5091                                ..Default::default()
5092                            },
5093                        )),
5094                    },
5095                    WebUIFragment::raw(">"),
5096                    WebUIFragment::component("child-component"),
5097                    WebUIFragment::raw("</child-component>"),
5098                ],
5099            },
5100        );
5101        fragments.insert(
5102            "parentDisabledTemplate".to_string(),
5103            FragmentList {
5104                fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
5105            },
5106        );
5107        fragments.insert(
5108            "child-component".to_string(),
5109            FragmentList {
5110                fragments: vec![
5111                    WebUIFragment::if_cond(
5112                        ConditionExpr::identifier("disabled"),
5113                        "childDisabledTemplate",
5114                    ),
5115                    WebUIFragment::if_cond(
5116                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
5117                        "childEnabledTemplate",
5118                    ),
5119                ],
5120            },
5121        );
5122        fragments.insert(
5123            "childDisabledTemplate".to_string(),
5124            FragmentList {
5125                fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
5126            },
5127        );
5128        fragments.insert(
5129            "childEnabledTemplate".to_string(),
5130            FragmentList {
5131                fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
5132            },
5133        );
5134
5135        // Test case 1: isDisabled = true
5136        {
5137            let protocol = WebUIProtocol::new(fragments.clone());
5138            let state = test_json!({"isDisabled": true});
5139            let mut writer = TestWriter::new();
5140            handle(
5141                &protocol,
5142                &state,
5143                &RenderOptions::new("index.html", "/"),
5144                &mut writer,
5145            )
5146            .unwrap();
5147            assert_eq!(
5148                writer.get_content(),
5149                "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
5150            );
5151        }
5152
5153        // Test case 2: isDisabled = false
5154        {
5155            let protocol = WebUIProtocol::new(fragments.clone());
5156            let state = test_json!({"isDisabled": false});
5157            let mut writer = TestWriter::new();
5158            handle(
5159                &protocol,
5160                &state,
5161                &RenderOptions::new("index.html", "/"),
5162                &mut writer,
5163            )
5164            .unwrap();
5165            assert_eq!(
5166                writer.get_content(),
5167                "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
5168            );
5169        }
5170    }
5171
5172    // ── GROUP 9: Hydration (SKIP) ─────────────────────────────────────
5173
5174    // TODO: test_hydration - requires FAST handler plugin integration; see plugin/fast.rs
5175
5176    // ── Component tests ──────────────────────────────────────────────
5177
5178    #[test]
5179    fn test_component_with_template() {
5180        let mut fragments = HashMap::new();
5181        fragments.insert(
5182            "index.html".to_string(),
5183            FragmentList {
5184                fragments: vec![
5185                    WebUIFragment::raw("<custom-element>"),
5186                    WebUIFragment::component("custom-element"),
5187                    WebUIFragment::raw("</custom-element>"),
5188                ],
5189            },
5190        );
5191        fragments.insert(
5192            "custom-element".to_string(),
5193            FragmentList {
5194                fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
5195            },
5196        );
5197        let protocol = WebUIProtocol::new(fragments);
5198        let state = test_json!({});
5199        let mut writer = TestWriter::new();
5200        handle(
5201            &protocol,
5202            &state,
5203            &RenderOptions::new("index.html", "/"),
5204            &mut writer,
5205        )
5206        .unwrap();
5207        assert_eq!(
5208            writer.get_content(),
5209            "<custom-element><div>Custom Element</div></custom-element>"
5210        );
5211        assert!(writer.is_ended());
5212    }
5213
5214    #[test]
5215    fn test_component_with_slots() {
5216        let mut fragments = HashMap::new();
5217        fragments.insert(
5218            "index.html".to_string(),
5219            FragmentList {
5220                fragments: vec![
5221                    WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
5222                    WebUIFragment::component("custom-element"),
5223                    WebUIFragment::raw("Hello World</custom-element>"),
5224                ],
5225            },
5226        );
5227        fragments.insert(
5228            "custom-element".to_string(),
5229            FragmentList {
5230                fragments: vec![WebUIFragment::raw("<slot></slot>")],
5231            },
5232        );
5233        let protocol = WebUIProtocol::new(fragments);
5234        let state = test_json!({});
5235        let mut writer = TestWriter::new();
5236        handle(
5237            &protocol,
5238            &state,
5239            &RenderOptions::new("index.html", "/"),
5240            &mut writer,
5241        )
5242        .unwrap();
5243        assert_eq!(
5244            writer.get_content(),
5245            "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
5246        );
5247        assert!(writer.is_ended());
5248    }
5249
5250    #[test]
5251    fn test_multiple_nested_components() {
5252        let mut fragments = HashMap::new();
5253        fragments.insert(
5254            "index.html".to_string(),
5255            FragmentList {
5256                fragments: vec![
5257                    WebUIFragment::raw("<div>"),
5258                    WebUIFragment::for_loop("item", "items", "templateRepeat"),
5259                    WebUIFragment::raw("</div>"),
5260                ],
5261            },
5262        );
5263        fragments.insert(
5264            "custom-button".to_string(),
5265            FragmentList {
5266                fragments: vec![WebUIFragment::raw("<slot></slot>")],
5267            },
5268        );
5269        fragments.insert(
5270            "custom-element".to_string(),
5271            FragmentList {
5272                fragments: vec![
5273                    WebUIFragment::raw("<custom-child>"),
5274                    WebUIFragment::component("custom-child"),
5275                    WebUIFragment::raw("</custom-child><slot></slot>"),
5276                ],
5277            },
5278        );
5279        fragments.insert(
5280            "custom-child".to_string(),
5281            FragmentList {
5282                fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
5283            },
5284        );
5285        fragments.insert(
5286            "templateRepeat".to_string(),
5287            FragmentList {
5288                fragments: vec![
5289                    WebUIFragment::raw("<custom-element>"),
5290                    WebUIFragment::component("custom-element"),
5291                    WebUIFragment::raw("<custom-button>"),
5292                    WebUIFragment::component("custom-button"),
5293                    WebUIFragment::raw("Ok</custom-button></custom-element>"),
5294                ],
5295            },
5296        );
5297        let protocol = WebUIProtocol::new(fragments);
5298        let state = test_json!({"items": [{"name": "Item1"}]});
5299        let mut writer = TestWriter::new();
5300        handle(
5301            &protocol,
5302            &state,
5303            &RenderOptions::new("index.html", "/"),
5304            &mut writer,
5305        )
5306        .unwrap();
5307        assert_eq!(
5308            writer.get_content(),
5309            "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
5310        );
5311        assert!(writer.is_ended());
5312    }
5313
5314    // ── Conditional tests ────────────────────────────────────────────
5315
5316    #[test]
5317    fn test_if_with_binary_expression() {
5318        let mut fragments = HashMap::new();
5319        fragments.insert(
5320            "index.html".to_string(),
5321            FragmentList {
5322                fragments: vec![
5323                    WebUIFragment::raw("<div>"),
5324                    WebUIFragment::if_cond(
5325                        ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
5326                        "if-1",
5327                    ),
5328                    WebUIFragment::raw("</div>"),
5329                ],
5330            },
5331        );
5332        fragments.insert(
5333            "if-1".to_string(),
5334            FragmentList {
5335                fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
5336            },
5337        );
5338        let protocol = WebUIProtocol::new(fragments);
5339
5340        // True case: x = 10 > 5
5341        let state_true = test_json!({"x": 10});
5342        let mut writer_true = TestWriter::new();
5343        handle(
5344            &protocol,
5345            &state_true,
5346            &RenderOptions::new("index.html", "/"),
5347            &mut writer_true,
5348        )
5349        .unwrap();
5350        assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
5351
5352        // False case: x = 1 <= 5
5353        let state_false = test_json!({"x": 1});
5354        let mut writer_false = TestWriter::new();
5355        handle(
5356            &protocol,
5357            &state_false,
5358            &RenderOptions::new("index.html", "/"),
5359            &mut writer_false,
5360        )
5361        .unwrap();
5362        assert_eq!(writer_false.get_content(), "<div></div>");
5363    }
5364
5365    #[test]
5366    fn test_for_if_overlapping_local_state() {
5367        let mut fragments = HashMap::new();
5368        fragments.insert(
5369            "index.html".to_string(),
5370            FragmentList {
5371                fragments: vec![
5372                    WebUIFragment::raw("<div>"),
5373                    WebUIFragment::for_loop("item", "items", "template1"),
5374                    WebUIFragment::raw("</div>"),
5375                ],
5376            },
5377        );
5378        fragments.insert(
5379            "template1".to_string(),
5380            FragmentList {
5381                fragments: vec![
5382                    WebUIFragment::raw("<div>"),
5383                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
5384                    WebUIFragment::raw("</div>"),
5385                ],
5386            },
5387        );
5388        fragments.insert(
5389            "ifBlock".to_string(),
5390            FragmentList {
5391                fragments: vec![
5392                    WebUIFragment::raw("<span>"),
5393                    WebUIFragment::signal("item.label", false),
5394                    WebUIFragment::raw("</span>"),
5395                ],
5396            },
5397        );
5398        let protocol = WebUIProtocol::new(fragments);
5399        let state = test_json!({
5400            "flag": false,
5401            "items": [
5402                {"label": "A", "flag": true},
5403                {"label": "B", "flag": false},
5404                {"label": "C", "flag": true}
5405            ]
5406        });
5407        let mut writer = TestWriter::new();
5408        handle(
5409            &protocol,
5410            &state,
5411            &RenderOptions::new("index.html", "/"),
5412            &mut writer,
5413        )
5414        .unwrap();
5415        assert_eq!(
5416            writer.get_content(),
5417            "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
5418        );
5419    }
5420
5421    #[test]
5422    fn test_for_if_global_flag_no_effect() {
5423        let mut fragments = HashMap::new();
5424        fragments.insert(
5425            "index.html".to_string(),
5426            FragmentList {
5427                fragments: vec![
5428                    WebUIFragment::raw("<div>"),
5429                    WebUIFragment::for_loop("item", "items", "template1"),
5430                    WebUIFragment::raw("</div>"),
5431                ],
5432            },
5433        );
5434        fragments.insert(
5435            "template1".to_string(),
5436            FragmentList {
5437                fragments: vec![
5438                    WebUIFragment::raw("<div>"),
5439                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
5440                    WebUIFragment::raw("</div>"),
5441                ],
5442            },
5443        );
5444        fragments.insert(
5445            "ifBlock".to_string(),
5446            FragmentList {
5447                fragments: vec![
5448                    WebUIFragment::raw("<span>"),
5449                    WebUIFragment::signal("item.label", false),
5450                    WebUIFragment::raw("</span>"),
5451                ],
5452            },
5453        );
5454        let protocol = WebUIProtocol::new(fragments);
5455        let state = test_json!({
5456            "item": {"flag": true},
5457            "items": [
5458                {"label": "A", "flag": false},
5459                {"label": "B", "flag": true},
5460                {"label": "C", "flag": false}
5461            ]
5462        });
5463        let mut writer = TestWriter::new();
5464        handle(
5465            &protocol,
5466            &state,
5467            &RenderOptions::new("index.html", "/"),
5468            &mut writer,
5469        )
5470        .unwrap();
5471        assert_eq!(
5472            writer.get_content(),
5473            "<div><div></div><div><span>B</span></div><div></div></div>"
5474        );
5475    }
5476
5477    // ── Recursive template test ──────────────────────────────────────
5478
5479    #[test]
5480    fn test_recursive_template_refs() {
5481        let mut fragments = HashMap::new();
5482        fragments.insert(
5483            "index.html".to_string(),
5484            FragmentList {
5485                fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
5486            },
5487        );
5488        fragments.insert(
5489            "static".to_string(),
5490            FragmentList {
5491                fragments: vec![
5492                    WebUIFragment::raw("<div expanded=\""),
5493                    WebUIFragment::signal("item.expanded", false),
5494                    WebUIFragment::raw("\" class=\""),
5495                    WebUIFragment::signal("testScenario", false),
5496                    WebUIFragment::raw("\"><span>"),
5497                    WebUIFragment::signal("item.name", false),
5498                    WebUIFragment::raw("</span>"),
5499                    WebUIFragment::for_loop("item", "item.children", "static"),
5500                    WebUIFragment::raw("</div>"),
5501                ],
5502            },
5503        );
5504        let protocol = WebUIProtocol::new(fragments);
5505        let state = test_json!({
5506            "testScenario": "RecursiveTemplatesWithGlobalState",
5507            "items": [
5508                {"name": "A", "expanded": "false", "children": []},
5509                {"name": "B", "expanded": "true", "children": [
5510                    {"name": "C", "expanded": "false"},
5511                    {"name": "D", "expanded": "false"}
5512                ]},
5513                {"name": "E", "expanded": "false"}
5514            ]
5515        });
5516        let mut writer = TestWriter::new();
5517        handle(
5518            &protocol,
5519            &state,
5520            &RenderOptions::new("index.html", "/"),
5521            &mut writer,
5522        )
5523        .unwrap();
5524        assert_eq!(
5525            writer.get_content(),
5526            "<div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>A</span></div><div expanded=\"true\" class=\"RecursiveTemplatesWithGlobalState\"><span>B</span><div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>C</span></div><div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>D</span></div></div><div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>E</span></div>"
5527        );
5528    }
5529
5530    // ── Advanced state management tests ──────────────────────────────
5531
5532    #[test]
5533    fn test_component_in_for_no_local_access() {
5534        let mut fragments = HashMap::new();
5535        fragments.insert(
5536            "index.html".to_string(),
5537            FragmentList {
5538                fragments: vec![
5539                    WebUIFragment::raw("<div>"),
5540                    WebUIFragment::for_loop("item", "items", "templateComponent"),
5541                    WebUIFragment::raw("</div>"),
5542                ],
5543            },
5544        );
5545        fragments.insert(
5546            "templateComponent".to_string(),
5547            FragmentList {
5548                fragments: vec![
5549                    WebUIFragment::raw("<component-tag>"),
5550                    WebUIFragment::component("my-component"),
5551                    WebUIFragment::raw("</component-tag>"),
5552                ],
5553            },
5554        );
5555        fragments.insert(
5556            "my-component".to_string(),
5557            FragmentList {
5558                fragments: vec![
5559                    WebUIFragment::raw("<span>"),
5560                    WebUIFragment::signal("name", false),
5561                    WebUIFragment::raw("</span>"),
5562                ],
5563            },
5564        );
5565        let protocol = WebUIProtocol::new(fragments);
5566        let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
5567        let mut writer = TestWriter::new();
5568        handle(
5569            &protocol,
5570            &state,
5571            &RenderOptions::new("index.html", "/"),
5572            &mut writer,
5573        )
5574        .unwrap();
5575        assert_eq!(
5576            writer.get_content(),
5577            "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
5578        );
5579    }
5580
5581    #[test]
5582    fn test_nested_for_hierarchical_state() {
5583        let mut fragments = HashMap::new();
5584        fragments.insert(
5585            "index.html".to_string(),
5586            FragmentList {
5587                fragments: vec![
5588                    WebUIFragment::raw("<div>"),
5589                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5590                    WebUIFragment::raw("</div>"),
5591                ],
5592            },
5593        );
5594        fragments.insert(
5595            "outerTemplate".to_string(),
5596            FragmentList {
5597                fragments: vec![
5598                    WebUIFragment::raw("<section>"),
5599                    WebUIFragment::signal("globalPrefix", false),
5600                    WebUIFragment::signal("outerItem.outerLabel", false),
5601                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5602                    WebUIFragment::raw("</section>"),
5603                ],
5604            },
5605        );
5606        fragments.insert(
5607            "innerTemplate".to_string(),
5608            FragmentList {
5609                fragments: vec![
5610                    WebUIFragment::raw("<p>"),
5611                    WebUIFragment::signal("globalPrefix", false),
5612                    WebUIFragment::signal("outerItem.outerLabel", false),
5613                    WebUIFragment::raw(": "),
5614                    WebUIFragment::signal("innerItem.innerLabel", false),
5615                    WebUIFragment::raw("</p>"),
5616                ],
5617            },
5618        );
5619        let protocol = WebUIProtocol::new(fragments);
5620        let state = test_json!({
5621            "globalPrefix": "Prefix: ",
5622            "outerItems": [
5623                {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
5624                {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
5625            ]
5626        });
5627        let mut writer = TestWriter::new();
5628        handle(
5629            &protocol,
5630            &state,
5631            &RenderOptions::new("index.html", "/"),
5632            &mut writer,
5633        )
5634        .unwrap();
5635        assert_eq!(
5636            writer.get_content(),
5637            "<div><section>Prefix: O1<p>Prefix: O1: I1</p><p>Prefix: O1: I2</p></section><section>Prefix: O2<p>Prefix: O2: I3</p></section></div>"
5638        );
5639    }
5640
5641    #[test]
5642    fn test_component_in_for_global_only() {
5643        let mut fragments = HashMap::new();
5644        fragments.insert(
5645            "index.html".to_string(),
5646            FragmentList {
5647                fragments: vec![
5648                    WebUIFragment::raw("<div>"),
5649                    WebUIFragment::for_loop("item", "items", "templateComponent"),
5650                    WebUIFragment::raw("</div>"),
5651                ],
5652            },
5653        );
5654        fragments.insert(
5655            "templateComponent".to_string(),
5656            FragmentList {
5657                fragments: vec![
5658                    WebUIFragment::raw("<component-tag>"),
5659                    WebUIFragment::component("my-component"),
5660                    WebUIFragment::raw("</component-tag>"),
5661                ],
5662            },
5663        );
5664        fragments.insert(
5665            "my-component".to_string(),
5666            FragmentList {
5667                fragments: vec![
5668                    WebUIFragment::raw("<span>"),
5669                    WebUIFragment::signal("name", false),
5670                    WebUIFragment::raw("-"),
5671                    WebUIFragment::signal("globalSuffix", false),
5672                    WebUIFragment::raw("</span>"),
5673                ],
5674            },
5675        );
5676        let protocol = WebUIProtocol::new(fragments);
5677        let state =
5678            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
5679        let mut writer = TestWriter::new();
5680        handle(
5681            &protocol,
5682            &state,
5683            &RenderOptions::new("index.html", "/"),
5684            &mut writer,
5685        )
5686        .unwrap();
5687        assert_eq!(
5688            writer.get_content(),
5689            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
5690        );
5691    }
5692
5693    #[test]
5694    fn test_component_no_item_moniker() {
5695        let mut fragments = HashMap::new();
5696        fragments.insert(
5697            "index.html".to_string(),
5698            FragmentList {
5699                fragments: vec![
5700                    WebUIFragment::raw("<div>"),
5701                    WebUIFragment::for_loop("item", "items", "templateComponent"),
5702                    WebUIFragment::raw("</div>"),
5703                ],
5704            },
5705        );
5706        fragments.insert(
5707            "templateComponent".to_string(),
5708            FragmentList {
5709                fragments: vec![
5710                    WebUIFragment::raw("<component-tag>"),
5711                    WebUIFragment::component("my-component"),
5712                    WebUIFragment::raw("</component-tag>"),
5713                ],
5714            },
5715        );
5716        fragments.insert(
5717            "my-component".to_string(),
5718            FragmentList {
5719                fragments: vec![
5720                    WebUIFragment::raw("<span>"),
5721                    WebUIFragment::signal("item.name", false),
5722                    WebUIFragment::raw("-"),
5723                    WebUIFragment::signal("globalSuffix", false),
5724                    WebUIFragment::raw("</span>"),
5725                ],
5726            },
5727        );
5728        let protocol = WebUIProtocol::new(fragments);
5729        let state =
5730            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
5731        let mut writer = TestWriter::new();
5732        handle(
5733            &protocol,
5734            &state,
5735            &RenderOptions::new("index.html", "/"),
5736            &mut writer,
5737        )
5738        .unwrap();
5739        assert_eq!(
5740            writer.get_content(),
5741            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
5742        );
5743    }
5744
5745    #[test]
5746    fn test_for_nonqualified_uses_global() {
5747        let mut fragments = HashMap::new();
5748        fragments.insert(
5749            "index.html".to_string(),
5750            FragmentList {
5751                fragments: vec![
5752                    WebUIFragment::raw("<div>"),
5753                    WebUIFragment::for_loop("item", "items", "template1"),
5754                    WebUIFragment::raw("</div>"),
5755                ],
5756            },
5757        );
5758        fragments.insert(
5759            "template1".to_string(),
5760            FragmentList {
5761                fragments: vec![
5762                    WebUIFragment::raw("<span>"),
5763                    WebUIFragment::signal("name", false),
5764                    WebUIFragment::raw("</span>"),
5765                ],
5766            },
5767        );
5768        let protocol = WebUIProtocol::new(fragments);
5769        let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
5770        let mut writer = TestWriter::new();
5771        handle(
5772            &protocol,
5773            &state,
5774            &RenderOptions::new("index.html", "/"),
5775            &mut writer,
5776        )
5777        .unwrap();
5778        assert_eq!(
5779            writer.get_content(),
5780            "<div><span>GlobalName</span><span>GlobalName</span></div>"
5781        );
5782    }
5783
5784    #[test]
5785    fn test_nested_for_if_interleaved() {
5786        let mut fragments = HashMap::new();
5787        fragments.insert(
5788            "index.html".to_string(),
5789            FragmentList {
5790                fragments: vec![
5791                    WebUIFragment::raw("<div>"),
5792                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5793                    WebUIFragment::raw("</div>"),
5794                ],
5795            },
5796        );
5797        fragments.insert(
5798            "outerTemplate".to_string(),
5799            FragmentList {
5800                fragments: vec![
5801                    WebUIFragment::raw("<section>"),
5802                    WebUIFragment::signal("globalPrefix", false),
5803                    WebUIFragment::signal("outerItem.outerLabel", false),
5804                    WebUIFragment::if_cond(
5805                        ConditionExpr::identifier("outerItem.include"),
5806                        "ifTemplate",
5807                    ),
5808                    WebUIFragment::raw("</section>"),
5809                ],
5810            },
5811        );
5812        fragments.insert(
5813            "ifTemplate".to_string(),
5814            FragmentList {
5815                fragments: vec![
5816                    WebUIFragment::raw("<div>"),
5817                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5818                    WebUIFragment::raw("</div>"),
5819                ],
5820            },
5821        );
5822        fragments.insert(
5823            "innerTemplate".to_string(),
5824            FragmentList {
5825                fragments: vec![
5826                    WebUIFragment::raw("<p>"),
5827                    WebUIFragment::signal("globalSuffix", false),
5828                    WebUIFragment::raw(": "),
5829                    WebUIFragment::signal("innerItem.innerLabel", false),
5830                    WebUIFragment::raw("</p>"),
5831                ],
5832            },
5833        );
5834        let protocol = WebUIProtocol::new(fragments);
5835        let state = test_json!({
5836            "globalPrefix": "Prefix: ",
5837            "globalSuffix": "Suffix",
5838            "outerItems": [
5839                {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
5840                {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
5841                {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
5842            ]
5843        });
5844        let mut writer = TestWriter::new();
5845        handle(
5846            &protocol,
5847            &state,
5848            &RenderOptions::new("index.html", "/"),
5849            &mut writer,
5850        )
5851        .unwrap();
5852        assert_eq!(
5853            writer.get_content(),
5854            "<div><section>Prefix: O1<div><p>Suffix: I1</p><p>Suffix: I2</p></div></section><section>Prefix: O2</section><section>Prefix: O3<div><p>Suffix: I3</p></div></section></div>"
5855        );
5856    }
5857
5858    #[test]
5859    fn test_nested_for_if_outer_state() {
5860        let mut fragments = HashMap::new();
5861        fragments.insert(
5862            "index.html".to_string(),
5863            FragmentList {
5864                fragments: vec![
5865                    WebUIFragment::raw("<div>"),
5866                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5867                    WebUIFragment::raw("</div>"),
5868                ],
5869            },
5870        );
5871        fragments.insert(
5872            "outerTemplate".to_string(),
5873            FragmentList {
5874                fragments: vec![
5875                    WebUIFragment::raw("<section>"),
5876                    WebUIFragment::signal("globalPrefix", false),
5877                    WebUIFragment::signal("outerItem.label", false),
5878                    WebUIFragment::for_loop(
5879                        "middleItem",
5880                        "outerItem.middleItems",
5881                        "middleTemplate",
5882                    ),
5883                    WebUIFragment::raw("</section>"),
5884                ],
5885            },
5886        );
5887        fragments.insert(
5888            "middleTemplate".to_string(),
5889            FragmentList {
5890                fragments: vec![
5891                    WebUIFragment::raw("<div>"),
5892                    WebUIFragment::if_cond(
5893                        ConditionExpr::identifier("outerItem.active"),
5894                        "ifTemplate",
5895                    ),
5896                    WebUIFragment::raw("</div>"),
5897                ],
5898            },
5899        );
5900        fragments.insert(
5901            "ifTemplate".to_string(),
5902            FragmentList {
5903                fragments: vec![
5904                    WebUIFragment::raw("<p>"),
5905                    WebUIFragment::signal("middleItem.value", false),
5906                    WebUIFragment::raw("</p>"),
5907                ],
5908            },
5909        );
5910        let protocol = WebUIProtocol::new(fragments);
5911        let state = test_json!({
5912            "globalPrefix": "GP-",
5913            "outerItems": [
5914                {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
5915                {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
5916                {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
5917            ]
5918        });
5919        let mut writer = TestWriter::new();
5920        handle(
5921            &protocol,
5922            &state,
5923            &RenderOptions::new("index.html", "/"),
5924            &mut writer,
5925        )
5926        .unwrap();
5927        assert_eq!(
5928            writer.get_content(),
5929            "<div><section>GP-O1<div><p>M1</p></div><div><p>M2</p></div></section><section>GP-O2<div></div></section><section>GP-O3<div><p>M4</p></div></section></div>"
5930        );
5931    }
5932
5933    #[test]
5934    fn test_nested_for_if_inner_state() {
5935        let mut fragments = HashMap::new();
5936        fragments.insert(
5937            "index.html".to_string(),
5938            FragmentList {
5939                fragments: vec![
5940                    WebUIFragment::raw("<div>"),
5941                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5942                    WebUIFragment::raw("</div>"),
5943                ],
5944            },
5945        );
5946        fragments.insert(
5947            "outerTemplate".to_string(),
5948            FragmentList {
5949                fragments: vec![
5950                    WebUIFragment::raw("<section>"),
5951                    WebUIFragment::signal("outerItem.label", false),
5952                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5953                    WebUIFragment::raw("</section>"),
5954                ],
5955            },
5956        );
5957        fragments.insert(
5958            "innerTemplate".to_string(),
5959            FragmentList {
5960                fragments: vec![
5961                    WebUIFragment::raw("<article>"),
5962                    WebUIFragment::if_cond(
5963                        ConditionExpr::identifier("innerItem.show"),
5964                        "ifTemplate",
5965                    ),
5966                    WebUIFragment::raw("</article>"),
5967                ],
5968            },
5969        );
5970        fragments.insert(
5971            "ifTemplate".to_string(),
5972            FragmentList {
5973                fragments: vec![
5974                    WebUIFragment::raw("<p>"),
5975                    WebUIFragment::signal("innerItem.detail", false),
5976                    WebUIFragment::raw("</p>"),
5977                ],
5978            },
5979        );
5980        let protocol = WebUIProtocol::new(fragments);
5981        let state = test_json!({
5982            "outerItems": [
5983                {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
5984                {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
5985            ]
5986        });
5987        let mut writer = TestWriter::new();
5988        handle(
5989            &protocol,
5990            &state,
5991            &RenderOptions::new("index.html", "/"),
5992            &mut writer,
5993        )
5994        .unwrap();
5995        assert_eq!(
5996            writer.get_content(),
5997            "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
5998        );
5999    }
6000
6001    #[test]
6002    fn test_for_merge_local_global_monikers() {
6003        let mut fragments = HashMap::new();
6004        fragments.insert(
6005            "index.html".to_string(),
6006            FragmentList {
6007                fragments: vec![
6008                    WebUIFragment::raw("<div>"),
6009                    WebUIFragment::for_loop("item", "items", "template1"),
6010                    WebUIFragment::raw("</div>"),
6011                ],
6012            },
6013        );
6014        fragments.insert(
6015            "template1".to_string(),
6016            FragmentList {
6017                fragments: vec![
6018                    WebUIFragment::raw("<span>"),
6019                    WebUIFragment::signal("item.name", false),
6020                    WebUIFragment::raw("-"),
6021                    WebUIFragment::signal("item.globalValue", false),
6022                    WebUIFragment::raw("-"),
6023                    WebUIFragment::signal("item.localOnly", false),
6024                    WebUIFragment::raw("-"),
6025                    WebUIFragment::signal("item.otherVal", false),
6026                    WebUIFragment::raw("</span>"),
6027                ],
6028            },
6029        );
6030        let protocol = WebUIProtocol::new(fragments);
6031        let state = test_json!({
6032            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
6033            "items": [
6034                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
6035                {"name": "Local2", "localOnly": "Only2"}
6036            ]
6037        });
6038        let mut writer = TestWriter::new();
6039        handle(
6040            &protocol,
6041            &state,
6042            &RenderOptions::new("index.html", "/"),
6043            &mut writer,
6044        )
6045        .unwrap();
6046        assert_eq!(
6047            writer.get_content(),
6048            "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
6049        );
6050    }
6051
6052    #[test]
6053    fn test_component_in_for_global_moniker_shadow() {
6054        let mut fragments = HashMap::new();
6055        fragments.insert(
6056            "index.html".to_string(),
6057            FragmentList {
6058                fragments: vec![
6059                    WebUIFragment::raw("<div>"),
6060                    WebUIFragment::for_loop("item", "items", "templateComponent"),
6061                    WebUIFragment::raw("</div>"),
6062                ],
6063            },
6064        );
6065        fragments.insert(
6066            "templateComponent".to_string(),
6067            FragmentList {
6068                fragments: vec![
6069                    WebUIFragment::raw("<component-tag>"),
6070                    WebUIFragment::component("my-component"),
6071                    WebUIFragment::raw("</component-tag>"),
6072                ],
6073            },
6074        );
6075        fragments.insert(
6076            "my-component".to_string(),
6077            FragmentList {
6078                fragments: vec![
6079                    WebUIFragment::raw("<span>"),
6080                    WebUIFragment::signal("name", false),
6081                    WebUIFragment::raw("-"),
6082                    WebUIFragment::signal("item.globalValue", false),
6083                    WebUIFragment::raw("-"),
6084                    WebUIFragment::signal("localOnly", false),
6085                    WebUIFragment::raw("-"),
6086                    WebUIFragment::signal("item.otherVal", false),
6087                    WebUIFragment::raw("</span>"),
6088                ],
6089            },
6090        );
6091        let protocol = WebUIProtocol::new(fragments);
6092        let state = test_json!({
6093            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
6094            "items": [
6095                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
6096                {"name": "Local2", "localOnly": "Only2"}
6097            ]
6098        });
6099        let mut writer = TestWriter::new();
6100        handle(
6101            &protocol,
6102            &state,
6103            &RenderOptions::new("index.html", "/"),
6104            &mut writer,
6105        )
6106        .unwrap();
6107        assert_eq!(
6108            writer.get_content(),
6109            "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
6110        );
6111    }
6112
6113    #[test]
6114    fn test_if_in_nested_for_local_flag() {
6115        let mut fragments = HashMap::new();
6116        fragments.insert(
6117            "index.html".to_string(),
6118            FragmentList {
6119                fragments: vec![
6120                    WebUIFragment::raw("<div>"),
6121                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
6122                    WebUIFragment::raw("</div>"),
6123                ],
6124            },
6125        );
6126        fragments.insert(
6127            "outerTemplate".to_string(),
6128            FragmentList {
6129                fragments: vec![
6130                    WebUIFragment::raw("<section>"),
6131                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
6132                    WebUIFragment::raw("</section>"),
6133                ],
6134            },
6135        );
6136        fragments.insert(
6137            "innerTemplate".to_string(),
6138            FragmentList {
6139                fragments: vec![WebUIFragment::if_cond(
6140                    ConditionExpr::identifier("inner_item.flag"),
6141                    "ifInner",
6142                )],
6143            },
6144        );
6145        fragments.insert(
6146            "ifInner".to_string(),
6147            FragmentList {
6148                fragments: vec![
6149                    WebUIFragment::raw("<p>"),
6150                    WebUIFragment::signal("inner_item.value", false),
6151                    WebUIFragment::raw("</p>"),
6152                ],
6153            },
6154        );
6155        let protocol = WebUIProtocol::new(fragments);
6156        let state = test_json!({
6157            "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
6158            "inner_item": {"flag": false}
6159        });
6160        let mut writer = TestWriter::new();
6161        handle(
6162            &protocol,
6163            &state,
6164            &RenderOptions::new("index.html", "/"),
6165            &mut writer,
6166        )
6167        .unwrap();
6168        assert_eq!(
6169            writer.get_content(),
6170            "<div><section><p>X</p></section></div>"
6171        );
6172    }
6173
6174    #[test]
6175    fn test_if_in_nested_for_global_fallback() {
6176        let mut fragments = HashMap::new();
6177        fragments.insert(
6178            "index.html".to_string(),
6179            FragmentList {
6180                fragments: vec![
6181                    WebUIFragment::raw("<div>"),
6182                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
6183                    WebUIFragment::raw("</div>"),
6184                ],
6185            },
6186        );
6187        fragments.insert(
6188            "outerTemplate".to_string(),
6189            FragmentList {
6190                fragments: vec![
6191                    WebUIFragment::raw("<section>"),
6192                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
6193                    WebUIFragment::raw("</section>"),
6194                ],
6195            },
6196        );
6197        fragments.insert(
6198            "innerTemplate".to_string(),
6199            FragmentList {
6200                fragments: vec![WebUIFragment::if_cond(
6201                    ConditionExpr::identifier("inner_item.flag"),
6202                    "ifInner",
6203                )],
6204            },
6205        );
6206        fragments.insert(
6207            "ifInner".to_string(),
6208            FragmentList {
6209                fragments: vec![
6210                    WebUIFragment::raw("<p>"),
6211                    WebUIFragment::signal("inner_item.value", false),
6212                    WebUIFragment::raw("</p>"),
6213                ],
6214            },
6215        );
6216        let protocol = WebUIProtocol::new(fragments);
6217        let state = test_json!({
6218            "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
6219            "inner_item": {"flag": true}
6220        });
6221        let mut writer = TestWriter::new();
6222        handle(
6223            &protocol,
6224            &state,
6225            &RenderOptions::new("index.html", "/"),
6226            &mut writer,
6227        )
6228        .unwrap();
6229        assert_eq!(
6230            writer.get_content(),
6231            "<div><section><p>X</p><p>Y</p></section></div>"
6232        );
6233    }
6234
6235    #[test]
6236    fn test_if_mixed_for_monikers() {
6237        let mut fragments = HashMap::new();
6238        fragments.insert(
6239            "index.html".to_string(),
6240            FragmentList {
6241                fragments: vec![
6242                    WebUIFragment::raw("<div>"),
6243                    WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
6244                    WebUIFragment::raw("</div>"),
6245                ],
6246            },
6247        );
6248        fragments.insert(
6249            "outerTemplate".to_string(),
6250            FragmentList {
6251                fragments: vec![
6252                    WebUIFragment::raw("<section>"),
6253                    WebUIFragment::signal("outer.outerLabel", false),
6254                    WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
6255                    WebUIFragment::raw("</section>"),
6256                ],
6257            },
6258        );
6259        fragments.insert(
6260            "innerTemplate".to_string(),
6261            FragmentList {
6262                fragments: vec![WebUIFragment::if_cond(
6263                    ConditionExpr::compound(
6264                        ConditionExpr::identifier("outer.active"),
6265                        LogicalOperator::And,
6266                        ConditionExpr::predicate(
6267                            "inner.value",
6268                            ComparisonOperator::GreaterThan,
6269                            "globalLimit",
6270                        ),
6271                    ),
6272                    "ifInner",
6273                )],
6274            },
6275        );
6276        fragments.insert(
6277            "ifInner".to_string(),
6278            FragmentList {
6279                fragments: vec![
6280                    WebUIFragment::raw("<p>"),
6281                    WebUIFragment::signal("inner.value", false),
6282                    WebUIFragment::raw("</p>"),
6283                ],
6284            },
6285        );
6286        let protocol = WebUIProtocol::new(fragments);
6287        let state = test_json!({
6288            "globalLimit": 10,
6289            "list": {"outerItems": [
6290                {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
6291                {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
6292                {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
6293            ]}
6294        });
6295        let mut writer = TestWriter::new();
6296        handle(
6297            &protocol,
6298            &state,
6299            &RenderOptions::new("index.html", "/"),
6300            &mut writer,
6301        )
6302        .unwrap();
6303        assert_eq!(
6304            writer.get_content(),
6305            "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
6306        );
6307    }
6308
6309    // ── Route-aware rendering tests ─────────────────────────────────────
6310
6311    fn make_route_protocol() -> WebUIProtocol {
6312        use webui_protocol::WebUiFragmentRoute;
6313
6314        let mut fragments = HashMap::new();
6315
6316        // Entry page with two routes
6317        fragments.insert(
6318            "index.html".to_string(),
6319            FragmentList {
6320                fragments: vec![
6321                    WebUIFragment::raw("<h1>Shell</h1>"),
6322                    WebUIFragment::route_from(WebUiFragmentRoute {
6323                        path: "/".into(),
6324                        fragment_id: "dash-page".into(),
6325                        exact: true,
6326                        keep_alive: false,
6327                        ..Default::default()
6328                    }),
6329                    WebUIFragment::route_from(WebUiFragmentRoute {
6330                        path: "/contacts/:id".into(),
6331                        fragment_id: "detail-page".into(),
6332                        exact: true,
6333                        keep_alive: false,
6334                        ..Default::default()
6335                    }),
6336                ],
6337            },
6338        );
6339
6340        // Dashboard page component
6341        fragments.insert(
6342            "dash-page".to_string(),
6343            FragmentList {
6344                fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
6345            },
6346        );
6347
6348        // Detail page component
6349        fragments.insert(
6350            "detail-page".to_string(),
6351            FragmentList {
6352                fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
6353            },
6354        );
6355
6356        WebUIProtocol::new(fragments)
6357    }
6358
6359    fn make_nested_route_protocol() -> WebUIProtocol {
6360        use webui_protocol::WebUiFragmentRoute;
6361
6362        let mut fragments = HashMap::new();
6363
6364        fragments.insert(
6365            "index.html".to_string(),
6366            FragmentList {
6367                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
6368                    path: "/".into(),
6369                    fragment_id: "app-shell".into(),
6370                    exact: false,
6371                    children: vec![WebUiFragmentRoute {
6372                        path: "sections/:id".into(),
6373                        fragment_id: "section-comp".into(),
6374                        exact: false,
6375                        children: vec![WebUiFragmentRoute {
6376                            path: "topics/:topicId".into(),
6377                            fragment_id: "topic-comp".into(),
6378                            exact: true,
6379                            children: vec![],
6380                            keep_alive: false,
6381                            ..Default::default()
6382                        }],
6383                        keep_alive: false,
6384                        ..Default::default()
6385                    }],
6386                    keep_alive: false,
6387                    ..Default::default()
6388                })],
6389            },
6390        );
6391
6392        fragments.insert(
6393            "app-shell".to_string(),
6394            FragmentList {
6395                fragments: vec![
6396                    WebUIFragment::raw("<h1>Shell</h1>"),
6397                    WebUIFragment::outlet(),
6398                ],
6399            },
6400        );
6401
6402        fragments.insert(
6403            "section-comp".to_string(),
6404            FragmentList {
6405                fragments: vec![
6406                    WebUIFragment::raw("<h2>Section</h2>"),
6407                    WebUIFragment::outlet(),
6408                ],
6409            },
6410        );
6411
6412        fragments.insert(
6413            "topic-comp".to_string(),
6414            FragmentList {
6415                fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
6416            },
6417        );
6418
6419        WebUIProtocol::new(fragments)
6420    }
6421
6422    #[test]
6423    fn test_route_renders_shell_always() {
6424        let protocol = make_route_protocol();
6425        let state = test_json!({});
6426        let mut writer = TestWriter::new();
6427        handle(
6428            &protocol,
6429            &state,
6430            &RenderOptions::new("index.html", "/"),
6431            &mut writer,
6432        )
6433        .unwrap();
6434        let html = writer.get_content();
6435
6436        // Shell content always renders regardless of route matching
6437        assert!(html.contains("<h1>Shell</h1>"), "shell should render");
6438        // Dashboard matches "/" so it should be active
6439        assert!(html.contains(" active>"), "matched route should be active");
6440        // Detail should be hidden and empty
6441        assert!(
6442            html.contains("style=\"display:none\""),
6443            "non-matched routes should be hidden"
6444        );
6445    }
6446
6447    #[test]
6448    fn test_route_matched_renders_visible() {
6449        let protocol = make_route_protocol();
6450        let state = test_json!({});
6451        let mut writer = TestWriter::new();
6452        handle(
6453            &protocol,
6454            &state,
6455            &RenderOptions::new("index.html", "/"),
6456            &mut writer,
6457        )
6458        .unwrap();
6459        let html = writer.get_content();
6460
6461        // Dashboard route should be visible (active, no display:none)
6462        assert!(
6463            html.contains("<webui-route path=\"/\""),
6464            "dashboard route should exist"
6465        );
6466        assert!(
6467            html.contains("active>") && html.contains("<dash-page>"),
6468            "matched route should be active with component tag: {html}"
6469        );
6470        assert!(
6471            html.contains("<p>Dashboard</p>"),
6472            "matched route should have content"
6473        );
6474    }
6475
6476    #[test]
6477    fn test_route_non_matched_renders_hidden_empty() {
6478        let protocol = make_route_protocol();
6479        let state = test_json!({});
6480        let mut writer = TestWriter::new();
6481        handle(
6482            &protocol,
6483            &state,
6484            &RenderOptions::new("index.html", "/"),
6485            &mut writer,
6486        )
6487        .unwrap();
6488        let html = writer.get_content();
6489
6490        // Detail route should be hidden and empty (no content rendered)
6491        assert!(
6492            html.contains("<webui-route path=\"/contacts/:id\""),
6493            "detail route element should exist"
6494        );
6495        // The non-matched route should have display:none and no inner content
6496        let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
6497        let after_detail = &html[detail_start..];
6498        assert!(
6499            after_detail.contains("style=\"display:none\">")
6500                && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
6501            "non-matched route should be hidden: {after_detail}"
6502        );
6503        // Should NOT contain the component's rendered content
6504        let detail_end = after_detail.find("</webui-route>").expect("closing tag");
6505        let detail_body = &after_detail[..detail_end];
6506        assert!(
6507            !detail_body.contains("<detail-page>"),
6508            "non-matched route should not render component content: {detail_body}"
6509        );
6510    }
6511
6512    #[test]
6513    fn test_route_parameterized_match() {
6514        let protocol = make_route_protocol();
6515        let state = test_json!({});
6516        let mut writer = TestWriter::new();
6517        handle(
6518            &protocol,
6519            &state,
6520            &RenderOptions::new("index.html", "/contacts/42"),
6521            &mut writer,
6522        )
6523        .unwrap();
6524        let html = writer.get_content();
6525
6526        // Detail route matches /contacts/42
6527        assert!(
6528            html.contains("active>") && html.contains("<detail-page>"),
6529            "detail route should be active: {html}"
6530        );
6531        assert!(html.contains("<p>Detail</p>"), "detail should have content");
6532        // Dashboard should be hidden + empty
6533        let dash_start = html
6534            .find("component=\"dash-page\"")
6535            .expect("dashboard route");
6536        let after_dash = &html[dash_start..];
6537        assert!(
6538            after_dash.contains("style=\"display:none\">"),
6539            "dashboard should be hidden when detail matches: {after_dash}"
6540        );
6541        let dash_end = after_dash.find("</webui-route>").expect("closing tag");
6542        let dash_body = &after_dash[..dash_end];
6543        assert!(
6544            !dash_body.contains("<dash-page>"),
6545            "dashboard should not render component content: {dash_body}"
6546        );
6547    }
6548
6549    #[test]
6550    fn test_route_no_match_all_hidden_empty() {
6551        let protocol = make_route_protocol();
6552        let state = test_json!({});
6553        let mut writer = TestWriter::new();
6554        handle(
6555            &protocol,
6556            &state,
6557            &RenderOptions::new("index.html", "/nonexistent"),
6558            &mut writer,
6559        )
6560        .unwrap();
6561        let html = writer.get_content();
6562
6563        // Shell content should still render
6564        assert!(html.contains("<h1>Shell</h1>"));
6565        // All routes should be hidden + empty (nothing matched)
6566        assert!(
6567            !html.contains("<p>Dashboard</p>"),
6568            "no route content when nothing matches"
6569        );
6570        assert!(
6571            !html.contains("<p>Detail</p>"),
6572            "no route content when nothing matches"
6573        );
6574    }
6575
6576    #[test]
6577    fn test_route_component_attr_emitted() {
6578        let protocol = make_route_protocol();
6579        let state = test_json!({});
6580        let mut writer = TestWriter::new();
6581        handle(
6582            &protocol,
6583            &state,
6584            &RenderOptions::new("index.html", "/"),
6585            &mut writer,
6586        )
6587        .unwrap();
6588        let html = writer.get_content();
6589        // component attribute should be emitted on webui-route
6590        assert!(
6591            html.contains("component=\"dash-page\""),
6592            "component attr should be on webui-route: {html}"
6593        );
6594        assert!(
6595            html.contains("component=\"detail-page\""),
6596            "component attr should be on webui-route: {html}"
6597        );
6598    }
6599
6600    #[test]
6601    fn test_no_plugin_no_state_attributes() {
6602        let protocol = make_route_protocol();
6603        let state = test_json!({
6604            "title": "Fish & Chips",
6605            "cartOpen": true,
6606            "items": [{"name": "A&B"}]
6607        });
6608        let mut writer = TestWriter::new();
6609
6610        handle(
6611            &protocol,
6612            &state,
6613            &RenderOptions::new("index.html", "/"),
6614            &mut writer,
6615        )
6616        .unwrap();
6617
6618        let html = writer.get_content();
6619        // Without a plugin, no state attributes at all
6620        assert!(
6621            !html.contains("data-state"),
6622            "no data-state without plugin: {html}"
6623        );
6624        assert!(
6625            !html.contains(r#"title="Fish"#),
6626            "no scalar attrs without plugin: {html}"
6627        );
6628    }
6629
6630    #[test]
6631    fn test_nested_routes_render_webui_route_as_light_dom() {
6632        let protocol = make_nested_route_protocol();
6633        let state = test_json!({"title": "Test"});
6634        let handler = WebUIHandler::new();
6635        let mut writer = TestWriter::new();
6636
6637        handler
6638            .handle(
6639                &protocol,
6640                &state,
6641                &RenderOptions::new("index.html", "/sections/frontend"),
6642                &mut writer,
6643            )
6644            .expect("render failed");
6645
6646        let html = writer.get_content();
6647
6648        assert!(
6649            html.contains("component=\"app-shell\"") && html.contains("active>"),
6650            "root route should be active: {html}"
6651        );
6652        // webui-route should NOT have shadow DOM — it's a light DOM structural element
6653        assert!(
6654            !html.contains("<template shadowrootmode"),
6655            "webui-route should be light DOM (no shadow template): {html}"
6656        );
6657    }
6658
6659    #[test]
6660    fn test_nested_routes_render_outlet_as_light_dom() {
6661        let protocol = make_nested_route_protocol();
6662        let state = test_json!({"title": "Test"});
6663        let handler = WebUIHandler::new();
6664        let mut writer = TestWriter::new();
6665
6666        handler
6667            .handle(
6668                &protocol,
6669                &state,
6670                &RenderOptions::new("index.html", "/sections/frontend"),
6671                &mut writer,
6672            )
6673            .expect("render failed");
6674
6675        let html = writer.get_content();
6676
6677        // No <webui-outlet> wrapper — routes render directly at outlet position
6678        assert!(
6679            !html.contains("<webui-outlet>"),
6680            "should not contain webui-outlet wrapper: {html}"
6681        );
6682        // Route elements should be in the output directly
6683        assert!(
6684            html.contains("<webui-route"),
6685            "should contain webui-route elements: {html}"
6686        );
6687    }
6688
6689    #[test]
6690    fn test_nested_routes_match_child_at_outlet() {
6691        let protocol = make_nested_route_protocol();
6692        let state = test_json!({"title": "Test"});
6693        let handler = WebUIHandler::new();
6694        let mut writer = TestWriter::new();
6695
6696        handler
6697            .handle(
6698                &protocol,
6699                &state,
6700                &RenderOptions::new("index.html", "/sections/frontend"),
6701                &mut writer,
6702            )
6703            .expect("render failed");
6704
6705        let html = writer.get_content();
6706
6707        assert!(
6708            html.contains("component=\"section-comp\"") && html.contains("active>"),
6709            "section route should be active: {html}"
6710        );
6711        assert!(
6712            html.contains("<h2>Section</h2>"),
6713            "section content should be present: {html}"
6714        );
6715    }
6716
6717    #[test]
6718    fn test_nested_routes_three_levels_deep() {
6719        let protocol = make_nested_route_protocol();
6720        let state = test_json!({"title": "Test"});
6721        let handler = WebUIHandler::new();
6722        let mut writer = TestWriter::new();
6723
6724        handler
6725            .handle(
6726                &protocol,
6727                &state,
6728                &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
6729                &mut writer,
6730            )
6731            .expect("render failed");
6732
6733        let html = writer.get_content();
6734
6735        assert!(
6736            html.contains("component=\"app-shell\"") && html.contains("active>"),
6737            "root active: {html}"
6738        );
6739        assert!(
6740            html.contains("component=\"section-comp\"") && html.contains("active>"),
6741            "section active: {html}"
6742        );
6743        assert!(
6744            html.contains("component=\"topic-comp\"")
6745                && html.contains("exact")
6746                && html.contains("active>"),
6747            "topic active: {html}"
6748        );
6749        assert!(
6750            html.contains("<p>Topic content</p>"),
6751            "leaf content present: {html}"
6752        );
6753    }
6754
6755    #[test]
6756    fn test_nested_routes_nonmatched_siblings_hidden() {
6757        let protocol = make_nested_route_protocol();
6758        let state = test_json!({"title": "Test"});
6759        let handler = WebUIHandler::new();
6760        let mut writer = TestWriter::new();
6761
6762        handler
6763            .handle(
6764                &protocol,
6765                &state,
6766                &RenderOptions::new("index.html", "/sections/frontend"),
6767                &mut writer,
6768            )
6769            .expect("render failed");
6770
6771        let html = writer.get_content();
6772
6773        assert!(
6774            html.contains(r#"component="topic-comp" exact style="display:none">"#),
6775            "topic should be hidden: {html}"
6776        );
6777    }
6778
6779    #[test]
6780    fn test_nested_routes_root_only() {
6781        let protocol = make_nested_route_protocol();
6782        let state = test_json!({"title": "Test"});
6783        let handler = WebUIHandler::new();
6784        let mut writer = TestWriter::new();
6785
6786        handler
6787            .handle(
6788                &protocol,
6789                &state,
6790                &RenderOptions::new("index.html", "/"),
6791                &mut writer,
6792            )
6793            .expect("render failed");
6794
6795        let html = writer.get_content();
6796
6797        assert!(
6798            html.contains("component=\"app-shell\"") && html.contains("active>"),
6799            "root active at /: {html}"
6800        );
6801        assert!(
6802            html.contains("<h1>Shell</h1>"),
6803            "shell renders at /: {html}"
6804        );
6805        assert!(
6806            html.contains(r#"component="section-comp" style="display:none">"#),
6807            "section hidden at /: {html}"
6808        );
6809    }
6810
6811    // ── CSS Module dedup tests ───────────────────────────────────────
6812
6813    #[test]
6814    fn test_css_module_emitted_once_inline_in_component() {
6815        // CSS module definition emitted once in the component's light DOM
6816        // on first render, not in <head> and not on second instance.
6817        let template = r#"<p><slot></slot></p>"#;
6818
6819        let mut fragments = HashMap::new();
6820        fragments.insert(
6821            "index.html".to_string(),
6822            FragmentList {
6823                fragments: vec![
6824                    WebUIFragment::raw("<html><head>".to_string()),
6825                    structural_fragment("head_end"),
6826                    WebUIFragment::raw("</head><body><div>".to_string()),
6827                    WebUIFragment::component("my-card"),
6828                    WebUIFragment::raw("A".to_string()),
6829                    WebUIFragment::component("my-card"),
6830                    WebUIFragment::raw("B</div>".to_string()),
6831                    structural_fragment("body_end"),
6832                    WebUIFragment::raw("</body></html>".to_string()),
6833                ],
6834            },
6835        );
6836        fragments.insert(
6837            "my-card".to_string(),
6838            FragmentList {
6839                fragments: vec![WebUIFragment::raw(template.to_string())],
6840            },
6841        );
6842
6843        let mut protocol = WebUIProtocol::new(fragments);
6844        protocol
6845            .components
6846            .entry("my-card".to_string())
6847            .or_default()
6848            .css = "p{color:red}".to_string();
6849        let state = test_json!({});
6850        let mut writer = TestWriter::new();
6851
6852        handle(
6853            &protocol,
6854            &state,
6855            &RenderOptions::new("index.html", "/"),
6856            &mut writer,
6857        )
6858        .unwrap();
6859
6860        let html = writer.get_content();
6861
6862        // CSS module importmap should appear exactly once
6863        let count = html.matches(r#"<script type="importmap""#).count();
6864        assert_eq!(
6865            count, 1,
6866            "CSS module importmap should be emitted once, got {count} in: {html}"
6867        );
6868        assert!(
6869            html.contains(r#""my-card":"data:text/css,"#),
6870            "Importmap must register my-card under a data: URI: {html}"
6871        );
6872
6873        // Template content should appear twice (once per component instance)
6874        let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
6875        assert_eq!(
6876            tmpl_count, 2,
6877            "Template should render twice, got {tmpl_count} in: {html}"
6878        );
6879
6880        // CSS module should be in <body> (inline), not in <head>
6881        let css_pos = html
6882            .find(r#"<script type="importmap""#)
6883            .expect("CSS module importmap missing");
6884        let body_pos = html.find("<body>").expect("<body> missing");
6885        assert!(
6886            css_pos > body_pos,
6887            "CSS module should be inline in component, not in <head>: {html}"
6888        );
6889    }
6890
6891    #[test]
6892    fn test_component_without_css_renders_normally() {
6893        // Components without CSS module prefix pass through unchanged
6894        let mut fragments = HashMap::new();
6895        fragments.insert(
6896            "index.html".to_string(),
6897            FragmentList {
6898                fragments: vec![WebUIFragment::component("my-card")],
6899            },
6900        );
6901        fragments.insert(
6902            "my-card".to_string(),
6903            FragmentList {
6904                fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
6905            },
6906        );
6907
6908        let protocol = WebUIProtocol::new(fragments);
6909        let state = test_json!({});
6910        let mut writer = TestWriter::new();
6911
6912        handle(
6913            &protocol,
6914            &state,
6915            &RenderOptions::new("index.html", "/"),
6916            &mut writer,
6917        )
6918        .unwrap();
6919
6920        let html = writer.get_content();
6921        assert!(
6922            html.contains("<p>hello</p>"),
6923            "Non-module component should render normally: {html}"
6924        );
6925    }
6926
6927    #[test]
6928    fn test_non_module_strategy_no_css_in_head() {
6929        // When component_css is empty (Link/Style strategies), no
6930        // CSS module importmap tags should appear in <head>.
6931        let template = r#"<p>hello</p>"#;
6932
6933        let mut fragments = HashMap::new();
6934        fragments.insert(
6935            "index.html".to_string(),
6936            FragmentList {
6937                fragments: vec![
6938                    WebUIFragment::raw("<html><head>".to_string()),
6939                    structural_fragment("head_end"),
6940                    WebUIFragment::raw("</head><body>".to_string()),
6941                    WebUIFragment::component("my-card"),
6942                    WebUIFragment::raw("</body></html>".to_string()),
6943                ],
6944            },
6945        );
6946        fragments.insert(
6947            "my-card".to_string(),
6948            FragmentList {
6949                fragments: vec![WebUIFragment::raw(template.to_string())],
6950            },
6951        );
6952
6953        // No component css populated — simulates Link/Style strategy
6954        let protocol = WebUIProtocol::new(fragments);
6955        let state = test_json!({});
6956        let mut writer = TestWriter::new();
6957
6958        handle(
6959            &protocol,
6960            &state,
6961            &RenderOptions::new("index.html", "/"),
6962            &mut writer,
6963        )
6964        .unwrap();
6965
6966        let html = writer.get_content();
6967
6968        assert!(
6969            !html.contains(r#"<style type="module""#),
6970            "Non-module strategy should not emit legacy CSS module tags in <head>: {html}"
6971        );
6972        assert!(
6973            !html.contains(r#"<script type="importmap""#),
6974            "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
6975        );
6976        assert!(
6977            html.contains("<p>hello</p>"),
6978            "Component should still render: {html}"
6979        );
6980    }
6981
6982    #[test]
6983    fn test_style_strategy_embeds_inline_style_in_shadow_template() {
6984        let mut fragments = HashMap::new();
6985        fragments.insert(
6986            "index.html".to_string(),
6987            FragmentList {
6988                fragments: vec![
6989                    WebUIFragment::raw("<html><head>".to_string()),
6990                    structural_fragment("head_end"),
6991                    WebUIFragment::raw("</head><body><my-card>".to_string()),
6992                    WebUIFragment::component("my-card"),
6993                    WebUIFragment::raw("</my-card></body></html>".to_string()),
6994                ],
6995            },
6996        );
6997        fragments.insert(
6998            "my-card".to_string(),
6999            FragmentList {
7000                fragments: vec![WebUIFragment::raw(
7001                    "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
7002                        .to_string(),
7003                )],
7004            },
7005        );
7006
7007        let protocol = WebUIProtocol::new(fragments);
7008        let state = test_json!({});
7009        let mut writer = TestWriter::new();
7010
7011        handle(
7012            &protocol,
7013            &state,
7014            &RenderOptions::new("index.html", "/"),
7015            &mut writer,
7016        )
7017        .unwrap();
7018
7019        let html = writer.get_content();
7020
7021        assert!(
7022            html.contains("<style>.card{color:red}</style>"),
7023            "Style strategy should embed inline CSS in shadow template: {html}"
7024        );
7025        assert!(
7026            !html.contains(r#"<style type="module""#),
7027            "Style strategy should not emit legacy module CSS in <head>: {html}"
7028        );
7029        assert!(
7030            !html.contains(r#"<script type="importmap""#),
7031            "Style strategy should not emit CSS module importmaps in <head>: {html}"
7032        );
7033    }
7034
7035    #[test]
7036    fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
7037        // Light DOM + Link strategy: handler emits <link rel="stylesheet">
7038        // in <head>. No preload tag — the stylesheet itself fetches.
7039        let mut fragments = HashMap::new();
7040        fragments.insert(
7041            "index.html".to_string(),
7042            FragmentList {
7043                fragments: vec![
7044                    WebUIFragment::raw("<html><head>".to_string()),
7045                    structural_fragment("head_end"),
7046                    WebUIFragment::raw("</head><body><my-card>".to_string()),
7047                    WebUIFragment::component("my-card"),
7048                    WebUIFragment::raw("</my-card>".to_string()),
7049                    structural_fragment("body_end"),
7050                    WebUIFragment::raw("</body></html>".to_string()),
7051                ],
7052            },
7053        );
7054        fragments.insert(
7055            "my-card".to_string(),
7056            FragmentList {
7057                fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
7058            },
7059        );
7060
7061        let mut protocol = WebUIProtocol::new(fragments);
7062        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
7063        protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
7064
7065        let comp = protocol
7066            .components
7067            .entry("my-card".to_string())
7068            .or_default();
7069        comp.css_href = "my-card.css".to_string();
7070        comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
7071
7072        let state = test_json!({});
7073        let mut writer = TestWriter::new();
7074
7075        handle(
7076            &protocol,
7077            &state,
7078            &RenderOptions::new("index.html", "/"),
7079            &mut writer,
7080        )
7081        .unwrap();
7082
7083        let html = writer.get_content();
7084
7085        let head_end = html.find("</head>").expect("</head> missing");
7086        let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css">"#);
7087        assert!(
7088            link_pos.is_some_and(|p| p < head_end),
7089            "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
7090        );
7091        assert!(
7092            !html.contains(r#"<link rel="preload""#),
7093            "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
7094        );
7095        assert!(
7096            !html.contains(r#"<style type="module""#),
7097            "Link strategy should not emit legacy module CSS: {html}"
7098        );
7099        assert!(
7100            !html.contains(r#"<script type="importmap""#),
7101            "Link strategy should not emit CSS module importmaps: {html}"
7102        );
7103    }
7104
7105    #[test]
7106    fn test_link_strategy_shadow_dom_emits_preload_in_head() {
7107        // Shadow DOM + Link strategy: handler emits <link rel="preload">
7108        // with data-webui-ssr-preload in <head>. No stylesheet — the shadow
7109        // root template already contains <link rel="stylesheet">.
7110        let mut fragments = HashMap::new();
7111        fragments.insert(
7112            "index.html".to_string(),
7113            FragmentList {
7114                fragments: vec![
7115                    WebUIFragment::raw("<html><head>".to_string()),
7116                    structural_fragment("head_end"),
7117                    WebUIFragment::raw("</head><body><o-loading-state>".to_string()),
7118                    WebUIFragment::component("o-loading-state"),
7119                    WebUIFragment::raw("</o-loading-state><my-card>".to_string()),
7120                    WebUIFragment::component("my-card"),
7121                    WebUIFragment::raw("</my-card>".to_string()),
7122                    structural_fragment("body_end"),
7123                    WebUIFragment::raw("</body></html>".to_string()),
7124                ],
7125            },
7126        );
7127        fragments.insert(
7128            "o-loading-state".to_string(),
7129            FragmentList {
7130                fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
7131            },
7132        );
7133        fragments.insert(
7134            "my-card".to_string(),
7135            FragmentList {
7136                fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
7137            },
7138        );
7139
7140        let mut protocol = WebUIProtocol::new(fragments);
7141        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
7142        protocol.set_dom_strategy(webui_protocol::DomStrategy::Shadow);
7143
7144        let comp1 = protocol
7145            .components
7146            .entry("o-loading-state".to_string())
7147            .or_default();
7148        comp1.css_href = "o-loading-state.css".to_string();
7149        comp1.template_json = r#"{"h":"<div>loading</div>"}"#.to_string();
7150
7151        let comp2 = protocol
7152            .components
7153            .entry("my-card".to_string())
7154            .or_default();
7155        comp2.css_href = "my-card.css".to_string();
7156        comp2.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
7157
7158        let state = test_json!({});
7159        let mut writer = TestWriter::new();
7160
7161        handle(
7162            &protocol,
7163            &state,
7164            &RenderOptions::new("index.html", "/"),
7165            &mut writer,
7166        )
7167        .unwrap();
7168
7169        let html = writer.get_content();
7170        let head_end = html.find("</head>").expect("</head> missing");
7171        let head_section = &html[..head_end];
7172
7173        // Both preload hints must be present with data-webui-ssr-preload attr
7174        assert!(
7175            head_section.contains(
7176                r#"<link rel="preload" href="o-loading-state.css" as="style" data-webui-ssr-preload="style">"#
7177            ),
7178            "Missing preload for o-loading-state.css in <head>: {html}"
7179        );
7180        assert!(
7181            head_section.contains(
7182                r#"<link rel="preload" href="my-card.css" as="style" data-webui-ssr-preload="style">"#
7183            ),
7184            "Missing preload for my-card.css in <head>: {html}"
7185        );
7186        // No stylesheet links — shadow root handles that
7187        assert!(
7188            !head_section.contains(r#"<link rel="stylesheet""#),
7189            "Shadow DOM should NOT emit <link rel=stylesheet> in <head>: {html}"
7190        );
7191    }
7192
7193    #[test]
7194    fn test_module_preloads_emit_in_head_in_compiler_order() {
7195        // The whole value of these hints is ordering: preloads are issued in
7196        // document order over one connection, so the largest chunk must go
7197        // first. The compiler sorts; the handler must not reorder or dedupe.
7198        let mut fragments = HashMap::new();
7199        fragments.insert(
7200            "index.html".to_string(),
7201            FragmentList {
7202                fragments: vec![
7203                    WebUIFragment::raw(
7204                        r#"<html><head><script type="module" async src="/index.js"></script>"#
7205                            .to_string(),
7206                    ),
7207                    structural_fragment("head_end"),
7208                    WebUIFragment::raw("</head><body>".to_string()),
7209                    structural_fragment("body_end"),
7210                    WebUIFragment::raw("</body></html>".to_string()),
7211                ],
7212            },
7213        );
7214
7215        let mut protocol = WebUIProtocol::new(fragments);
7216        protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
7217
7218        let state = test_json!({});
7219        let mut writer = TestWriter::new();
7220        handle(
7221            &protocol,
7222            &state,
7223            &RenderOptions::new("index.html", "/"),
7224            &mut writer,
7225        )
7226        .unwrap();
7227
7228        let html = writer.get_content();
7229        let head_end = html.find("</head>").expect("</head> missing");
7230        let head = &html[..head_end];
7231        assert!(
7232            head.contains(
7233                r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
7234            ),
7235            "hints must appear in <head> in the compiler's order: {html}"
7236        );
7237    }
7238
7239    #[test]
7240    fn test_no_module_preloads_emits_nothing() {
7241        let mut fragments = HashMap::new();
7242        fragments.insert(
7243            "index.html".to_string(),
7244            FragmentList {
7245                fragments: vec![
7246                    WebUIFragment::raw("<html><head>".to_string()),
7247                    structural_fragment("head_end"),
7248                    WebUIFragment::raw("</head><body>".to_string()),
7249                    structural_fragment("body_end"),
7250                    WebUIFragment::raw("</body></html>".to_string()),
7251                ],
7252            },
7253        );
7254        let protocol = WebUIProtocol::new(fragments);
7255
7256        let state = test_json!({});
7257        let mut writer = TestWriter::new();
7258        handle(
7259            &protocol,
7260            &state,
7261            &RenderOptions::new("index.html", "/"),
7262            &mut writer,
7263        )
7264        .unwrap();
7265
7266        assert!(
7267            !writer.get_content().contains("modulepreload"),
7268            "a build without hints must be byte-identical to before"
7269        );
7270    }
7271
7272    #[test]
7273    fn test_link_strategy_head_links_follow_document_order() {
7274        // Regression for #381: Link-strategy <head> CSS <link> tags must be
7275        // emitted in document/traversal order, not alphabetical tag order.
7276        // Document order here is <z-widget> then <a-widget>; an alphabetical
7277        // sort would (incorrectly) place a-widget first.
7278        let mut fragments = HashMap::new();
7279        fragments.insert(
7280            "index.html".to_string(),
7281            FragmentList {
7282                fragments: vec![
7283                    WebUIFragment::raw("<html><head>".to_string()),
7284                    structural_fragment("head_end"),
7285                    WebUIFragment::raw("</head><body><z-widget>".to_string()),
7286                    WebUIFragment::component("z-widget"),
7287                    WebUIFragment::raw("</z-widget><a-widget>".to_string()),
7288                    WebUIFragment::component("a-widget"),
7289                    WebUIFragment::raw("</a-widget>".to_string()),
7290                    structural_fragment("body_end"),
7291                    WebUIFragment::raw("</body></html>".to_string()),
7292                ],
7293            },
7294        );
7295        fragments.insert(
7296            "z-widget".to_string(),
7297            FragmentList {
7298                fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
7299            },
7300        );
7301        fragments.insert(
7302            "a-widget".to_string(),
7303            FragmentList {
7304                fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
7305            },
7306        );
7307
7308        let mut protocol = WebUIProtocol::new(fragments);
7309        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
7310        protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
7311
7312        let z = protocol
7313            .components
7314            .entry("z-widget".to_string())
7315            .or_default();
7316        z.css_href = "z-widget.css".to_string();
7317        z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
7318
7319        let a = protocol
7320            .components
7321            .entry("a-widget".to_string())
7322            .or_default();
7323        a.css_href = "a-widget.css".to_string();
7324        a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
7325
7326        let state = test_json!({});
7327        let mut writer = TestWriter::new();
7328
7329        handle(
7330            &protocol,
7331            &state,
7332            &RenderOptions::new("index.html", "/"),
7333            &mut writer,
7334        )
7335        .unwrap();
7336
7337        let html = writer.get_content();
7338        let head_end = html.find("</head>").expect("</head> missing");
7339        let head_section = &html[..head_end];
7340
7341        let z_pos = head_section
7342            .find(r#"<link rel="stylesheet" href="z-widget.css">"#)
7343            .expect("z-widget stylesheet link missing from <head>");
7344        let a_pos = head_section
7345            .find(r#"<link rel="stylesheet" href="a-widget.css">"#)
7346            .expect("a-widget stylesheet link missing from <head>");
7347
7348        assert!(
7349            z_pos < a_pos,
7350            "CSS <link> tags must follow document order (z-widget before \
7351             a-widget), not alphabetical order: {html}"
7352        );
7353    }
7354
7355    #[test]
7356    fn test_css_module_emitted_in_component_light_dom() {
7357        // CSS module <style> tags are emitted inline in the component's light DOM,
7358        // not in <head>. This keeps SSR output lean — only rendered components
7359        // get their style definitions.
7360        let mut fragments = HashMap::new();
7361        fragments.insert(
7362            "index.html".to_string(),
7363            FragmentList {
7364                fragments: vec![
7365                    WebUIFragment::raw("<html><head>".to_string()),
7366                    structural_fragment("head_end"),
7367                    WebUIFragment::raw("</head><body><my-card>".to_string()),
7368                    WebUIFragment::component("my-card"),
7369                    WebUIFragment::raw("</my-card>".to_string()),
7370                    WebUIFragment::raw("</body></html>".to_string()),
7371                ],
7372            },
7373        );
7374        fragments.insert(
7375            "my-card".to_string(),
7376            FragmentList {
7377                fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
7378            },
7379        );
7380
7381        let mut protocol = WebUIProtocol::new(fragments);
7382        protocol
7383            .components
7384            .entry("my-card".to_string())
7385            .or_default()
7386            .css = "p{color:red}".to_string();
7387        let state = test_json!({});
7388        let mut writer = TestWriter::new();
7389
7390        handle(
7391            &protocol,
7392            &state,
7393            &RenderOptions::new("index.html", "/"),
7394            &mut writer,
7395        )
7396        .unwrap();
7397
7398        let html = writer.get_content();
7399
7400        // CSS module importmap must be INSIDE the component tag (light DOM)
7401        let tag_open = html.find("<my-card>").expect("<my-card> missing");
7402        let css_pos = html
7403            .find(r#"<script type="importmap""#)
7404            .expect("CSS module importmap missing");
7405        let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
7406        assert!(
7407            css_pos > tag_open && css_pos < tag_close,
7408            "CSS module should be inside component light DOM: {html}"
7409        );
7410
7411        // <head> should NOT contain module styles
7412        let head_end = html.find("</head>").expect("</head> missing");
7413        assert!(
7414            css_pos > head_end,
7415            "CSS module should not be in <head>: {html}"
7416        );
7417    }
7418
7419    #[test]
7420    fn test_css_module_emitted_for_route_components() {
7421        // Route components get CSS modules emitted inline in their light DOM.
7422        let template = r#"<h1>Dashboard</h1>"#;
7423
7424        let mut fragments = HashMap::new();
7425        fragments.insert(
7426            "index.html".to_string(),
7427            FragmentList {
7428                fragments: vec![
7429                    WebUIFragment::raw("<html><head>".to_string()),
7430                    structural_fragment("head_end"),
7431                    WebUIFragment::raw("</head><body>".to_string()),
7432                    WebUIFragment::route("/", "dash-page"),
7433                    structural_fragment("body_end"),
7434                    WebUIFragment::raw("</body></html>".to_string()),
7435                ],
7436            },
7437        );
7438        fragments.insert(
7439            "dash-page".to_string(),
7440            FragmentList {
7441                fragments: vec![WebUIFragment::raw(template.to_string())],
7442            },
7443        );
7444
7445        let mut protocol = WebUIProtocol::new(fragments);
7446        let comp = protocol
7447            .components
7448            .entry("dash-page".to_string())
7449            .or_default();
7450        comp.css = "h1{font-size:2rem}".to_string();
7451        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
7452        let state = test_json!({});
7453        let mut writer = TestWriter::new();
7454
7455        handle(
7456            &protocol,
7457            &state,
7458            &RenderOptions::new("index.html", "/"),
7459            &mut writer,
7460        )
7461        .unwrap();
7462
7463        let html = writer.get_content();
7464
7465        assert!(
7466            html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
7467            "Route component should have CSS module importmap with data: URI: {html}"
7468        );
7469        assert!(
7470            html.contains("<h1>Dashboard</h1>"),
7471            "Route component should render content: {html}"
7472        );
7473    }
7474
7475    #[test]
7476    fn test_head_css_link_skipped_for_components_without_css() {
7477        // Regression: components without CSS files must not get <link> tags
7478        // in <head>, otherwise the browser requests a 404.
7479        let mut fragments = HashMap::new();
7480        fragments.insert(
7481            "index.html".to_string(),
7482            FragmentList {
7483                fragments: vec![
7484                    WebUIFragment::raw("<html><head>".to_string()),
7485                    structural_fragment("head_end"),
7486                    WebUIFragment::raw("</head><body>".to_string()),
7487                    WebUIFragment::component("has-css"),
7488                    WebUIFragment::component("no-css"),
7489                    structural_fragment("body_end"),
7490                    WebUIFragment::raw("</body></html>".to_string()),
7491                ],
7492            },
7493        );
7494        fragments.insert(
7495            "has-css".to_string(),
7496            FragmentList {
7497                fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
7498            },
7499        );
7500        fragments.insert(
7501            "no-css".to_string(),
7502            FragmentList {
7503                fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
7504            },
7505        );
7506
7507        let mut protocol = WebUIProtocol::new(fragments);
7508        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
7509        protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
7510
7511        // Only has-css has an external stylesheet (Link strategy)
7512        protocol
7513            .components
7514            .entry("has-css".to_string())
7515            .or_default()
7516            .css_href = "has-css.css".to_string();
7517
7518        let state = test_json!({});
7519        let mut writer = TestWriter::new();
7520
7521        let handler = WebUIHandler::with_plugin(|| {
7522            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7523        });
7524        handler
7525            .handle(
7526                &protocol,
7527                &state,
7528                &RenderOptions::new("index.html", "/"),
7529                &mut writer,
7530            )
7531            .unwrap();
7532
7533        let html = writer.get_content();
7534        assert!(
7535            html.contains(r#"<link rel="stylesheet" href="has-css.css">"#),
7536            "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
7537        );
7538        assert!(
7539            !html.contains("no-css.css"),
7540            "Component without CSS must NOT get a <link> in <head>: {html}"
7541        );
7542    }
7543
7544    #[test]
7545    fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
7546        // Simulates a page where app-shell renders cart-panel, but cart-panel
7547        // contains an <if> block with product-card inside. When the condition
7548        // is false (empty cart), product-card is NOT rendered — but it IS
7549        // reachable from the fragment graph. Its template metadata and CSS module
7550        // definition must be in the output so the client can mount it when
7551        // the <if> flips true. However, its bit must NOT be set in the
7552        // inventory — the inventory tracks what was actually rendered.
7553        let mut fragments = HashMap::new();
7554        fragments.insert(
7555            "index.html".to_string(),
7556            FragmentList {
7557                fragments: vec![
7558                    WebUIFragment::raw("<html><head>".to_string()),
7559                    structural_fragment("head_end"),
7560                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
7561                    WebUIFragment::component("app-shell"),
7562                    WebUIFragment::raw("</app-shell>".to_string()),
7563                    structural_fragment("body_end"),
7564                    WebUIFragment::raw("</body></html>".to_string()),
7565                ],
7566            },
7567        );
7568        // app-shell contains a cart panel
7569        fragments.insert(
7570            "app-shell".to_string(),
7571            FragmentList {
7572                fragments: vec![
7573                    WebUIFragment::raw("<div>Shell</div>".to_string()),
7574                    WebUIFragment::component("cart-panel"),
7575                ],
7576            },
7577        );
7578        // cart-panel has an <if> block containing product-card
7579        fragments.insert(
7580            "cart-panel".to_string(),
7581            FragmentList {
7582                fragments: vec![
7583                    WebUIFragment::raw("<aside>".to_string()),
7584                    WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
7585                    WebUIFragment::raw("</aside>".to_string()),
7586                ],
7587            },
7588        );
7589        // cart-items (if block body) contains product-card
7590        fragments.insert(
7591            "cart-items".to_string(),
7592            FragmentList {
7593                fragments: vec![WebUIFragment::component("product-card")],
7594            },
7595        );
7596        fragments.insert(
7597            "product-card".to_string(),
7598            FragmentList {
7599                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
7600            },
7601        );
7602
7603        let mut protocol = WebUIProtocol::new(fragments);
7604        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7605        for name in ["app-shell", "cart-panel", "product-card"] {
7606            let comp = protocol.components.entry(name.to_string()).or_default();
7607            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
7608            comp.css = format!(".{name}{{display:block}}");
7609            if name == "cart-panel" {
7610                comp.hydration_mode = StateProjectionMode::Keys as i32;
7611                comp.hydration_keys = vec!["hasItems".to_string()];
7612            }
7613            if name == "product-card" {
7614                comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
7615            }
7616        }
7617
7618        // Render with hasItems=false — product-card should NOT be rendered
7619        let state = test_json!({ "hasItems": false });
7620        let mut writer = TestWriter::new();
7621
7622        let handler = WebUIHandler::with_plugin(|| {
7623            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7624        });
7625        handler
7626            .handle(
7627                &protocol,
7628                &state,
7629                &RenderOptions::new("index.html", "/"),
7630                &mut writer,
7631            )
7632            .unwrap();
7633
7634        let html = writer.get_content();
7635
7636        assert!(
7637            html.contains(r#"<script type="application/json" id="webui-data">"#),
7638            "non-executable SSR metadata should be emitted in the webui-data block: {html}"
7639        );
7640        assert!(
7641            html.contains(r#""state":{"hasItems":false}"#),
7642            "SSR state should live in the JSON data block: {html}"
7643        );
7644        assert!(
7645            html.contains(r#""inventory":"#),
7646            "SSR inventory should live in the JSON data block: {html}"
7647        );
7648        assert!(
7649            !html.contains("window.__webui={\""),
7650            "executable bootstrap must not embed the window.__webui JSON literal: {html}"
7651        );
7652        assert!(
7653            !html.contains(r#"document.getElementById("webui-data")"#),
7654            "SSR must not parse webui-data; client packages own that lazy load: {html}"
7655        );
7656        assert!(
7657            !html.contains("window.__webui=w;"),
7658            "executable bootstrap must not replace existing window.__webui registrations: {html}"
7659        );
7660        assert!(
7661            !html.contains("w.templateFns={\""),
7662            "template function emission must not replace existing templateFns registrations: {html}"
7663        );
7664        assert!(
7665            html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
7666            "template functions should merge into the flat templateFns registry: {html}"
7667        );
7668
7669        // product-card template IS in the output — it's a known component
7670        // whose template must be available for client-side <if> activation.
7671        assert!(
7672            html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
7673            "product-card template should be emitted even when unrendered: {html}"
7674        );
7675
7676        // product-card CSS module IS in the output — reachable components need
7677        // their stylesheet definitions for client-side <if> activation.
7678        assert!(
7679            html.contains(r#""product-card":"data:text/css,"#),
7680            "reachable product-card CSS module importmap should be emitted: {html}"
7681        );
7682
7683        // app-shell and cart-panel SHOULD be in the output (they were rendered)
7684        assert!(
7685            html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
7686            "rendered app-shell template should be emitted: {html}"
7687        );
7688        assert!(
7689            html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
7690            "rendered cart-panel template should be emitted: {html}"
7691        );
7692    }
7693
7694    // ── CSP nonce on CSS module importmap ───────────────────────────
7695    //
7696    // When `RenderOptions::with_nonce(...)` is set, every inline
7697    // `<script type="importmap">` definition emitted during SSR for a
7698    // component CSS module must include `nonce="VALUE"` so strict CSP
7699    // `script-src 'nonce-...'` policies allow it. The without-nonce case
7700    // is already covered by other CSS module tests (e.g.
7701    // `test_css_module_emitted_for_route_components`).
7702
7703    #[test]
7704    fn test_css_module_emits_nonce_attribute_when_nonce_set() {
7705        // Per-component first-render path (`emit_css_module`).
7706        let template = r#"<h1>Dashboard</h1>"#;
7707
7708        let mut fragments = HashMap::new();
7709        fragments.insert(
7710            "index.html".to_string(),
7711            FragmentList {
7712                fragments: vec![
7713                    WebUIFragment::raw("<html><head>".to_string()),
7714                    structural_fragment("head_end"),
7715                    WebUIFragment::raw("</head><body>".to_string()),
7716                    WebUIFragment::route("/", "dash-page"),
7717                    structural_fragment("body_end"),
7718                    WebUIFragment::raw("</body></html>".to_string()),
7719                ],
7720            },
7721        );
7722        fragments.insert(
7723            "dash-page".to_string(),
7724            FragmentList {
7725                fragments: vec![WebUIFragment::raw(template.to_string())],
7726            },
7727        );
7728
7729        let mut protocol = WebUIProtocol::new(fragments);
7730        let comp = protocol
7731            .components
7732            .entry("dash-page".to_string())
7733            .or_default();
7734        comp.css = "h1{font-size:2rem}".to_string();
7735        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
7736        let state = test_json!({});
7737        let mut writer = TestWriter::new();
7738
7739        handle(
7740            &protocol,
7741            &state,
7742            &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
7743            &mut writer,
7744        )
7745        .unwrap();
7746
7747        let html = writer.get_content();
7748
7749        assert!(
7750            html.contains(
7751                r#"<script type="importmap" nonce="test-nonce-123">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
7752            ),
7753            "CSS module importmap tag should include nonce attribute in canonical order: {html}"
7754        );
7755    }
7756
7757    #[test]
7758    fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
7759        // Body-end emission path for reachable-but-unrendered components
7760        // (the second site touched by the patch). Triggered via a false
7761        // `<if>` block under hydration; requires the WebUI plugin so the
7762        // body_end hook executes.
7763        let mut fragments = HashMap::new();
7764        fragments.insert(
7765            "index.html".to_string(),
7766            FragmentList {
7767                fragments: vec![
7768                    WebUIFragment::raw("<html><head>".to_string()),
7769                    structural_fragment("head_end"),
7770                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
7771                    WebUIFragment::component("app-shell"),
7772                    WebUIFragment::raw("</app-shell>".to_string()),
7773                    structural_fragment("body_end"),
7774                    WebUIFragment::raw("</body></html>".to_string()),
7775                ],
7776            },
7777        );
7778        fragments.insert(
7779            "app-shell".to_string(),
7780            FragmentList {
7781                fragments: vec![WebUIFragment::if_cond(
7782                    ConditionExpr::identifier("hasItems"),
7783                    "cart-items",
7784                )],
7785            },
7786        );
7787        fragments.insert(
7788            "cart-items".to_string(),
7789            FragmentList {
7790                fragments: vec![WebUIFragment::component("product-card")],
7791            },
7792        );
7793        fragments.insert(
7794            "product-card".to_string(),
7795            FragmentList {
7796                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
7797            },
7798        );
7799
7800        let mut protocol = WebUIProtocol::new(fragments);
7801        for name in ["app-shell", "product-card"] {
7802            let comp = protocol.components.entry(name.to_string()).or_default();
7803            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
7804            comp.css = format!(".{name}{{display:block}}");
7805        }
7806
7807        // Render with hasItems=false so product-card is reachable but not
7808        // rendered, forcing its CSS module emission through the body_end path.
7809        let state = test_json!({ "hasItems": false });
7810        let mut writer = TestWriter::new();
7811
7812        let handler = WebUIHandler::with_plugin(|| {
7813            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7814        });
7815        handler
7816            .handle(
7817                &protocol,
7818                &state,
7819                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
7820                &mut writer,
7821            )
7822            .unwrap();
7823
7824        let html = writer.get_content();
7825
7826        assert!(
7827            html.contains(
7828                r#"<script type="importmap" nonce="test-nonce-123">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
7829            ),
7830            "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
7831        );
7832    }
7833
7834    #[test]
7835    fn projected_state_excludes_non_hydration_keys() -> Result<()> {
7836        let mut fragments = HashMap::new();
7837        fragments.insert(
7838            "index.html".to_string(),
7839            FragmentList {
7840                fragments: vec![
7841                    WebUIFragment::raw("<html><body><style>".to_string()),
7842                    WebUIFragment::signal("tokens.light", true),
7843                    WebUIFragment::raw("</style>".to_string()),
7844                    structural_fragment("body_end"),
7845                    WebUIFragment::raw("</body></html>".to_string()),
7846                ],
7847            },
7848        );
7849        fragments.insert(
7850            "app-shell".to_string(),
7851            FragmentList {
7852                fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
7853            },
7854        );
7855        let index_fragments = fragments
7856            .get_mut("index.html")
7857            .expect("index fixture should exist");
7858        index_fragments
7859            .fragments
7860            .insert(1, WebUIFragment::component("app-shell"));
7861        let mut protocol = WebUIProtocol::new(fragments);
7862        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7863        // Only `name` is a hydration key. `tokens` is a server-only field
7864        // (used above to resolve SSR CSS variables) and is NOT in the component
7865        // hydration keys,
7866        // so projection MUST keep it out of the client state block.
7867        protocol.components.insert(
7868            "app-shell".to_string(),
7869            webui_protocol::ComponentData {
7870                hydration_mode: StateProjectionMode::Keys as i32,
7871                hydration_keys: vec!["name".to_string()],
7872                ..Default::default()
7873            },
7874        );
7875        let state = test_json!({
7876            "name": "Alice",
7877            "tokens": {
7878                "light": "--color-brand: red;"
7879            }
7880        });
7881        let mut writer = TestWriter::new();
7882        let handler = WebUIHandler::with_plugin(|| {
7883            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7884        });
7885        handler.handle(
7886            &protocol,
7887            &state,
7888            &RenderOptions::new("index.html", "/"),
7889            &mut writer,
7890        )?;
7891        let output = writer.get_content();
7892
7893        // SSR still reads `tokens` to resolve the inline <style>...
7894        assert!(output.contains("--color-brand: red;"));
7895        // ...but only the hydration key reaches the client state.
7896        assert!(output.contains(r#""name":"Alice""#));
7897        assert!(!output.contains(r#""tokens""#));
7898        Ok(())
7899    }
7900
7901    #[test]
7902    fn full_initial_strategy_preserves_complete_state() -> Result<()> {
7903        let mut fragments = HashMap::new();
7904        fragments.insert(
7905            "index.html".to_string(),
7906            FragmentList {
7907                fragments: vec![
7908                    WebUIFragment::raw("<html><body>"),
7909                    structural_fragment("body_end"),
7910                    WebUIFragment::raw("</body></html>"),
7911                ],
7912            },
7913        );
7914        let protocol = WebUIProtocol::new(fragments);
7915        let state = test_json!({
7916            "client": "visible",
7917            "serverOnly": "also preserved",
7918        });
7919        let handler = WebUIHandler::with_plugin(|| {
7920            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7921        });
7922        let mut writer = TestWriter::new();
7923        handler.handle(
7924            &protocol,
7925            &state,
7926            &RenderOptions::new("index.html", "/"),
7927            &mut writer,
7928        )?;
7929        let output = writer.get_content();
7930        assert!(output.contains(r#""client":"visible""#));
7931        assert!(output.contains(r#""serverOnly":"also preserved""#));
7932        Ok(())
7933    }
7934
7935    #[test]
7936    fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
7937        let mut fragments = HashMap::new();
7938        fragments.insert(
7939            "index.html".to_string(),
7940            FragmentList {
7941                fragments: vec![
7942                    WebUIFragment::raw("<html><body>"),
7943                    WebUIFragment::component("app-shell"),
7944                    structural_fragment("body_end"),
7945                    WebUIFragment::raw("</body></html>"),
7946                ],
7947            },
7948        );
7949        fragments.insert(
7950            "app-shell".to_string(),
7951            FragmentList {
7952                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
7953            },
7954        );
7955        let mut protocol = WebUIProtocol::new(fragments);
7956        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7957        protocol.components.insert(
7958            "app-shell".to_string(),
7959            webui_protocol::ComponentData {
7960                hydration_mode: StateProjectionMode::All as i32,
7961                ..Default::default()
7962            },
7963        );
7964        let state = test_json!({
7965            "known": "value",
7966            "possiblyInherited": "must not be dropped",
7967        });
7968        let handler = WebUIHandler::with_plugin(|| {
7969            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7970        });
7971        let mut writer = TestWriter::new();
7972        handler.handle(
7973            &protocol,
7974            &state,
7975            &RenderOptions::new("index.html", "/"),
7976            &mut writer,
7977        )?;
7978        let output = writer.get_content();
7979        assert!(output.contains(r#""known":"value""#));
7980        assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
7981        Ok(())
7982    }
7983
7984    #[test]
7985    fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
7986        let mut fragments = HashMap::new();
7987        fragments.insert(
7988            "index.html".to_string(),
7989            FragmentList {
7990                fragments: vec![
7991                    WebUIFragment::raw("<html><body>"),
7992                    WebUIFragment::component("app-shell"),
7993                    structural_fragment("body_end"),
7994                    WebUIFragment::raw("</body></html>"),
7995                ],
7996            },
7997        );
7998        fragments.insert(
7999            "app-shell".to_string(),
8000            FragmentList {
8001                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
8002            },
8003        );
8004        let mut protocol = WebUIProtocol::new(fragments);
8005        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
8006        let state = test_json!({
8007            "known": "value",
8008            "serverOnly": "must not be dropped",
8009        });
8010        let handler = WebUIHandler::with_plugin(|| {
8011            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8012        });
8013        let mut writer = TestWriter::new();
8014        handler.handle(
8015            &protocol,
8016            &state,
8017            &RenderOptions::new("index.html", "/"),
8018            &mut writer,
8019        )?;
8020        let output = writer.get_content();
8021        assert!(output.contains(r#""known":"value""#));
8022        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
8023        Ok(())
8024    }
8025
8026    #[test]
8027    fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
8028        let mut fragments = HashMap::new();
8029        fragments.insert(
8030            "index.html".to_string(),
8031            FragmentList {
8032                fragments: vec![
8033                    WebUIFragment::raw("<html><body>"),
8034                    WebUIFragment::component("app-shell"),
8035                    structural_fragment("body_end"),
8036                    WebUIFragment::raw("</body></html>"),
8037                ],
8038            },
8039        );
8040        fragments.insert(
8041            "app-shell".to_string(),
8042            FragmentList {
8043                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
8044            },
8045        );
8046        let mut protocol = WebUIProtocol::new(fragments);
8047        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
8048        protocol.components.insert(
8049            "app-shell".to_string(),
8050            webui_protocol::ComponentData {
8051                hydration_mode: i32::MAX,
8052                ..Default::default()
8053            },
8054        );
8055        let state = test_json!({
8056            "known": "value",
8057            "serverOnly": "must not be dropped",
8058        });
8059        let handler = WebUIHandler::with_plugin(|| {
8060            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8061        });
8062        let mut writer = TestWriter::new();
8063        handler.handle(
8064            &protocol,
8065            &state,
8066            &RenderOptions::new("index.html", "/"),
8067            &mut writer,
8068        )?;
8069        let output = writer.get_content();
8070        assert!(output.contains(r#""known":"value""#));
8071        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
8072        Ok(())
8073    }
8074
8075    #[test]
8076    fn legacy_navigation_keys_with_default_mode_remain_keyed() {
8077        let mut protocol = WebUIProtocol::new(HashMap::new());
8078        protocol.components.insert(
8079            "app-shell".to_string(),
8080            webui_protocol::ComponentData {
8081                navigation_keys: vec!["selected".to_string()],
8082                ..Default::default()
8083            },
8084        );
8085
8086        match collect_navigation_state(&protocol, ["app-shell"]) {
8087            StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
8088            StateSelection::Full | StateSelection::BorrowedKeys(_) => {
8089                panic!("legacy navigation keys should remain owned and projected")
8090            }
8091        }
8092    }
8093
8094    #[test]
8095    fn legacy_navigation_without_projection_metadata_preserves_full_state() {
8096        let mut protocol = WebUIProtocol::new(HashMap::new());
8097        protocol.components.insert(
8098            "app-shell".to_string(),
8099            webui_protocol::ComponentData::default(),
8100        );
8101
8102        assert!(matches!(
8103            collect_navigation_state(&protocol, ["app-shell"]),
8104            StateSelection::Full
8105        ));
8106    }
8107
8108    #[test]
8109    fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
8110        let mut fragments = HashMap::new();
8111        fragments.insert(
8112            "index.html".to_string(),
8113            FragmentList {
8114                fragments: vec![
8115                    WebUIFragment::raw("<html><body>".to_string()),
8116                    structural_fragment("body_end"),
8117                    WebUIFragment::raw("</body></html>".to_string()),
8118                ],
8119            },
8120        );
8121        let mut protocol = WebUIProtocol::new(fragments);
8122        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
8123        let state = test_json!({
8124            "title": "Legacy state",
8125            "serverOnly": "preserved",
8126        });
8127        let handler = WebUIHandler::with_plugin(|| {
8128            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8129        });
8130
8131        let mut writer = TestWriter::new();
8132        handler.handle(
8133            &protocol,
8134            &state,
8135            &RenderOptions::new("index.html", "/"),
8136            &mut writer,
8137        )?;
8138        assert!(writer.get_content().contains(r#""state":{}"#));
8139        assert!(!writer.get_content().contains("Legacy state"));
8140        assert!(!writer.get_content().contains("preserved"));
8141        Ok(())
8142    }
8143
8144    #[test]
8145    fn scriptless_component_state_is_navigation_only() -> Result<()> {
8146        let mut fragments = HashMap::new();
8147        fragments.insert(
8148            "index.html".to_string(),
8149            FragmentList {
8150                fragments: vec![
8151                    WebUIFragment::raw("<html><body>"),
8152                    WebUIFragment::component("items-page"),
8153                    structural_fragment("body_end"),
8154                    WebUIFragment::raw("</body></html>"),
8155                ],
8156            },
8157        );
8158        fragments.insert(
8159            "items-page".to_string(),
8160            FragmentList {
8161                fragments: vec![WebUIFragment::raw("<p>Items</p>")],
8162            },
8163        );
8164        let mut protocol = WebUIProtocol::new(fragments);
8165        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
8166        protocol.components.insert(
8167            "items-page".to_string(),
8168            webui_protocol::ComponentData {
8169                template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
8170                navigation_mode: Some(StateProjectionMode::Keys as i32),
8171                navigation_keys: vec!["items".into()],
8172                ..Default::default()
8173            },
8174        );
8175        let state = test_json!({
8176            "items": ["STATE_SENTINEL"],
8177            "serverOnly": "SECRET_SENTINEL",
8178        });
8179        let handler = WebUIHandler::with_plugin(|| {
8180            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8181        });
8182        let mut writer = TestWriter::new();
8183
8184        handler.handle(
8185            &protocol,
8186            &state,
8187            &RenderOptions::new("index.html", "/"),
8188            &mut writer,
8189        )?;
8190
8191        let output = writer.get_content();
8192        assert!(output.contains(r#""state":{}"#));
8193        assert!(!output.contains("STATE_SENTINEL"));
8194        assert!(!output.contains("SECRET_SENTINEL"));
8195        Ok(())
8196    }
8197
8198    #[test]
8199    fn write_selected_state_projects_and_escapes() {
8200        // `keep` is in the sorted key set and its value contains a `</` that
8201        // must be escaped; `drop` is absent and must be projected out.
8202        let state = test_json!({
8203            "drop": "secret",
8204            "keep": "</script><b>"
8205        });
8206        let keys = ["keep"];
8207        let selection = StateSelection::Keys(keys.to_vec());
8208        let mut sink = TestWriter::new();
8209        let mut scratch = Vec::new();
8210        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
8211        assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
8212    }
8213
8214    #[test]
8215    fn write_selected_state_non_object_projection_emits_empty_object() {
8216        let state = test_json!("scalar state has nothing hydratable");
8217        let keys: [&str; 0] = [];
8218        let selection = StateSelection::Keys(keys.to_vec());
8219        let mut sink = TestWriter::new();
8220        let mut scratch = Vec::new();
8221        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
8222        assert_eq!(sink.get_content(), "{}");
8223    }
8224
8225    #[test]
8226    fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
8227        let state = test_json!({
8228            "keptA": 1,
8229            "keptB": 2,
8230            "serverOnlyA": 3,
8231            "serverOnlyB": 4,
8232        });
8233        let keys = ["keptA", "keptA", "keptB", "missing"];
8234        let selection = StateSelection::Keys(keys.to_vec());
8235        let mut sink = TestWriter::new();
8236        let mut scratch = Vec::new();
8237        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
8238        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
8239    }
8240
8241    #[test]
8242    fn write_selected_state_map_first_matches_schema_first_output() {
8243        let state = test_json!({
8244            "keptA": 1,
8245            "keptB": 2,
8246        });
8247        let keys = ["keptA", "keptB", "missingA", "missingB"];
8248        let selection = StateSelection::Keys(keys.to_vec());
8249        let mut sink = TestWriter::new();
8250        let mut scratch = Vec::new();
8251        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
8252        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
8253    }
8254
8255    #[test]
8256    fn write_selected_state_full_preserves_and_escapes_state() {
8257        let state = test_json!({
8258            "serverOnly": "</script><b>",
8259            "value": 42,
8260        });
8261        let mut sink = TestWriter::new();
8262        let mut scratch = Vec::new();
8263        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
8264        assert_eq!(
8265            sink.get_content(),
8266            r#"{"serverOnly":"<\/script><b>","value":42}"#
8267        );
8268    }
8269
8270    #[test]
8271    fn json_scratch_capacity_reused_across_checkpoint_payloads() {
8272        // Simulates several checkpoint bootstraps serialized in one render: a
8273        // single request-local buffer must grow once for the largest payload and
8274        // then be reused for subsequent payloads without reallocating. This is
8275        // the deterministic seam behind the streaming path's allocation gate —
8276        // `write_script_safe_json` is `pub(crate)`, so no public API is exposed.
8277        let mut sink = TestWriter::new();
8278        let mut scratch: Vec<u8> = Vec::new();
8279        // No allocation until serialization actually needs it.
8280        assert_eq!(scratch.capacity(), 0);
8281
8282        // The largest payload first establishes the high-water capacity.
8283        let mut large = serde_json::Map::new();
8284        for index in 0..64 {
8285            large.insert(
8286                format!("component_{index:04}"),
8287                Value::String("x".repeat(48)),
8288            );
8289        }
8290        let large = Value::Object(large);
8291        write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
8292        let high_water = scratch.capacity();
8293        assert!(high_water > 0);
8294
8295        // Subsequent smaller checkpoint payloads reuse the same buffer: capacity
8296        // never grows (no per-checkpoint allocation) and never shrinks.
8297        for seq in 0..8 {
8298            let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
8299            write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
8300            assert_eq!(
8301                scratch.capacity(),
8302                high_water,
8303                "scratch reallocated on checkpoint {seq}"
8304            );
8305        }
8306    }
8307
8308    #[test]
8309    fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
8310        let mut fragments = HashMap::new();
8311        fragments.insert(
8312            "index.html".to_string(),
8313            FragmentList {
8314                fragments: vec![
8315                    WebUIFragment::raw("<html><body>"),
8316                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
8317                        path: "/".to_string(),
8318                        fragment_id: "home-page".to_string(),
8319                        exact: true,
8320                        ..Default::default()
8321                    }),
8322                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
8323                        path: "/admin".to_string(),
8324                        fragment_id: "admin-page".to_string(),
8325                        exact: true,
8326                        ..Default::default()
8327                    }),
8328                    structural_fragment("body_end"),
8329                    WebUIFragment::raw("</body></html>"),
8330                ],
8331            },
8332        );
8333        fragments.insert(
8334            "home-page".to_string(),
8335            FragmentList {
8336                fragments: vec![WebUIFragment::raw("<p>Home</p>")],
8337            },
8338        );
8339        fragments.insert(
8340            "admin-page".to_string(),
8341            FragmentList {
8342                fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
8343            },
8344        );
8345
8346        let mut protocol = WebUIProtocol::new(fragments);
8347        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
8348        protocol.components.insert(
8349            "home-page".to_string(),
8350            webui_protocol::ComponentData {
8351                template_json: "{}".to_string(),
8352                hydration_mode: StateProjectionMode::Keys as i32,
8353                hydration_keys: vec!["homeTitle".to_string()],
8354                ..Default::default()
8355            },
8356        );
8357        protocol.components.insert(
8358            "admin-page".to_string(),
8359            webui_protocol::ComponentData {
8360                template_json: "{}".to_string(),
8361                hydration_mode: StateProjectionMode::Keys as i32,
8362                hydration_keys: vec!["adminToken".to_string()],
8363                ..Default::default()
8364            },
8365        );
8366        let state = test_json!({
8367            "homeTitle": "Welcome",
8368            "adminToken": "TOP_SECRET_SENTINEL",
8369        });
8370        let handler = WebUIHandler::with_plugin(|| {
8371            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8372        });
8373        let mut writer = TestWriter::new();
8374        handler.handle(
8375            &protocol,
8376            &state,
8377            &RenderOptions::new("index.html", "/"),
8378            &mut writer,
8379        )?;
8380        let output = writer.get_content();
8381        assert!(output.contains(r#""homeTitle":"Welcome""#));
8382        assert!(!output.contains("TOP_SECRET_SENTINEL"));
8383        assert!(!output.contains(r#""adminToken""#));
8384        Ok(())
8385    }
8386
8387    #[test]
8388    fn test_component_attr_name_aria() {
8389        // component_attr_name correctly maps ARIA attributes via the shared table
8390        assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
8391        assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
8392        assert_eq!(
8393            component_attr_name("aria-activedescendant"),
8394            "ariaActiveDescendant"
8395        );
8396        assert_eq!(component_attr_name("aria-label"), "ariaLabel");
8397        assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
8398    }
8399
8400    #[test]
8401    fn test_component_attr_name_html_global() {
8402        assert_eq!(component_attr_name("readonly"), "readOnly");
8403        assert_eq!(component_attr_name("tabindex"), "tabIndex");
8404        assert_eq!(component_attr_name("accesskey"), "accessKey");
8405        assert_eq!(component_attr_name("contenteditable"), "contentEditable");
8406        assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
8407        assert_eq!(component_attr_name("inputmode"), "inputMode");
8408        assert_eq!(component_attr_name("maxlength"), "maxLength");
8409        assert_eq!(component_attr_name("minlength"), "minLength");
8410        assert_eq!(component_attr_name("novalidate"), "noValidate");
8411        assert_eq!(component_attr_name("formaction"), "formAction");
8412        assert_eq!(component_attr_name("ismap"), "isMap");
8413        assert_eq!(component_attr_name("usemap"), "useMap");
8414    }
8415
8416    #[test]
8417    fn test_component_attr_name_strips_colon() {
8418        assert_eq!(component_attr_name(":readonly"), "readOnly");
8419        assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
8420        assert_eq!(component_attr_name(":data-title"), "dataTitle");
8421    }
8422
8423    #[test]
8424    fn test_component_attr_name_regular() {
8425        assert_eq!(component_attr_name("data-title"), "dataTitle");
8426        assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
8427        assert_eq!(component_attr_name("simple"), "simple");
8428    }
8429
8430    // ── allowed_query SSR emission tests ─────────────────────────────
8431
8432    fn make_query_route_protocol() -> WebUIProtocol {
8433        use webui_protocol::WebUiFragmentRoute;
8434
8435        let mut fragments = HashMap::new();
8436
8437        fragments.insert(
8438            "index.html".to_string(),
8439            FragmentList {
8440                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
8441                    path: "/".into(),
8442                    fragment_id: "app-shell".into(),
8443                    exact: false,
8444                    children: vec![
8445                        WebUiFragmentRoute {
8446                            path: "compose".into(),
8447                            fragment_id: "compose-page".into(),
8448                            exact: true,
8449                            allowed_query: "action,to,subject".into(),
8450                            keep_alive: false,
8451                            ..Default::default()
8452                        },
8453                        WebUiFragmentRoute {
8454                            path: "settings".into(),
8455                            fragment_id: "settings-page".into(),
8456                            exact: true,
8457                            keep_alive: false,
8458                            ..Default::default()
8459                        },
8460                    ],
8461                    keep_alive: false,
8462                    ..Default::default()
8463                })],
8464            },
8465        );
8466
8467        fragments.insert(
8468            "app-shell".to_string(),
8469            FragmentList {
8470                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
8471            },
8472        );
8473        fragments.insert(
8474            "compose-page".to_string(),
8475            FragmentList {
8476                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
8477            },
8478        );
8479        fragments.insert(
8480            "settings-page".to_string(),
8481            FragmentList {
8482                fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
8483            },
8484        );
8485
8486        WebUIProtocol::new(fragments)
8487    }
8488
8489    #[test]
8490    fn test_matched_route_omits_query_attr_from_dom() {
8491        let protocol = make_query_route_protocol();
8492        let state = test_json!({});
8493        let handler = WebUIHandler::new();
8494        let mut writer = TestWriter::new();
8495
8496        handler
8497            .handle(
8498                &protocol,
8499                &state,
8500                &RenderOptions::new("index.html", "/compose"),
8501                &mut writer,
8502            )
8503            .expect("render failed");
8504
8505        let html = writer.get_content();
8506        // query attr is no longer in DOM — it's in the SSR chain JSON instead
8507        assert!(
8508            !html.contains(r#"query="action,to,subject""#),
8509            "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
8510        );
8511    }
8512
8513    #[test]
8514    fn test_nonmatched_route_omits_query_attr_from_dom() {
8515        let protocol = make_query_route_protocol();
8516        let state = test_json!({});
8517        let handler = WebUIHandler::new();
8518        let mut writer = TestWriter::new();
8519
8520        handler
8521            .handle(
8522                &protocol,
8523                &state,
8524                &RenderOptions::new("index.html", "/settings"),
8525                &mut writer,
8526            )
8527            .expect("render failed");
8528
8529        let html = writer.get_content();
8530        // query attr should not be on hidden siblings either
8531        assert!(
8532            !html.contains(r#"query="#),
8533            "hidden route should not have query attr: {html}"
8534        );
8535    }
8536
8537    #[test]
8538    fn test_route_without_query_has_no_query_attr() {
8539        let protocol = make_query_route_protocol();
8540        let state = test_json!({});
8541        let handler = WebUIHandler::new();
8542        let mut writer = TestWriter::new();
8543
8544        handler
8545            .handle(
8546                &protocol,
8547                &state,
8548                &RenderOptions::new("index.html", "/settings"),
8549                &mut writer,
8550            )
8551            .expect("render failed");
8552
8553        let html = writer.get_content();
8554        // Find the settings route element and verify it has no query attr
8555        let settings_idx = html
8556            .find(r#"component="settings-page""#)
8557            .expect("settings route should exist");
8558        let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
8559        assert!(
8560            !settings_tag.contains("query="),
8561            "route without allowed_query should not emit query attr: {settings_tag}"
8562        );
8563    }
8564
8565    // ── Per-render head_inject / body_inject (replaces the byte-scanner
8566    //    InjectingStreamingWriter approach with structural signal-based
8567    //    injection) ───────────────────────────────────────────────────
8568
8569    fn build_head_body_protocol() -> WebUIProtocol {
8570        let mut fragments = HashMap::new();
8571        fragments.insert(
8572            "index.html".to_string(),
8573            FragmentList {
8574                fragments: vec![
8575                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
8576                    structural_fragment("head_end"),
8577                    WebUIFragment::raw("</head><body>hello".to_string()),
8578                    structural_fragment("body_end"),
8579                    WebUIFragment::raw("</body></html>".to_string()),
8580                ],
8581            },
8582        );
8583        WebUIProtocol::new(fragments)
8584    }
8585
8586    #[test]
8587    fn head_inject_emits_at_head_end_boundary() {
8588        let protocol = build_head_body_protocol();
8589        let state = test_json!({});
8590        let mut writer = TestWriter::new();
8591        let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
8592        handle(&protocol, &state, &opts, &mut writer).unwrap();
8593        let html = writer.get_content();
8594        // The inject must appear immediately before `</head>`.
8595        let inject_idx = html
8596            .find("<link rel=preload>")
8597            .expect("inject HTML missing");
8598        let head_close_idx = html.find("</head>").expect("</head> missing");
8599        assert!(
8600            inject_idx < head_close_idx,
8601            "head_inject must appear before </head>: {html}"
8602        );
8603        // No duplicate.
8604        assert_eq!(html.matches("<link rel=preload>").count(), 1);
8605    }
8606
8607    #[test]
8608    fn component_render_policy_css_emits_once_in_head_with_nonce() {
8609        let mut protocol = build_head_body_protocol();
8610        protocol.component_render_css = concat!(
8611            r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
8612            "contain-intrinsic-block-size:auto 18rem;}"
8613        )
8614        .to_string();
8615        let state = test_json!({});
8616        let mut writer = TestWriter::new();
8617        let opts = RenderOptions::new("index.html", "/").with_nonce("test-nonce");
8618        handle(&protocol, &state, &opts, &mut writer).unwrap();
8619        let html = writer.get_content();
8620
8621        let style = concat!(
8622            r#"<style data-webui-render-policy nonce="test-nonce">"#,
8623            r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
8624            "contain-intrinsic-block-size:auto 18rem;}</style>"
8625        );
8626        let style_index = html.find(style).expect("render policy style missing");
8627        let head_close = html.find("</head>").expect("</head> missing");
8628        assert!(style_index < head_close);
8629        assert_eq!(html.matches("data-webui-render-policy").count(), 1);
8630    }
8631
8632    #[test]
8633    fn body_inject_emits_at_body_end_boundary() {
8634        let protocol = build_head_body_protocol();
8635        let state = test_json!({});
8636        let mut writer = TestWriter::new();
8637        let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
8638        handle(&protocol, &state, &opts, &mut writer).unwrap();
8639        let html = writer.get_content();
8640        let inject_idx = html
8641            .find("<script>lr</script>")
8642            .expect("inject HTML missing");
8643        let body_close_idx = html.find("</body>").expect("</body> missing");
8644        assert!(
8645            inject_idx < body_close_idx,
8646            "body_inject must appear before </body>: {html}"
8647        );
8648        assert_eq!(html.matches("<script>lr</script>").count(), 1);
8649    }
8650
8651    // ── Reserved `$webui` state inject namespace ──────────────────────
8652
8653    /// Protocol carrying all three structural boundaries, so `body_start`
8654    /// placement can be asserted alongside `head_end` / `body_end`.
8655    fn build_all_boundaries_protocol() -> WebUIProtocol {
8656        let mut fragments = HashMap::new();
8657        fragments.insert(
8658            "index.html".to_string(),
8659            FragmentList {
8660                fragments: vec![
8661                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
8662                    structural_fragment("head_end"),
8663                    WebUIFragment::raw("</head><body>".to_string()),
8664                    structural_fragment("body_start"),
8665                    WebUIFragment::raw("hello".to_string()),
8666                    structural_fragment("body_end"),
8667                    WebUIFragment::raw("</body></html>".to_string()),
8668                ],
8669            },
8670        );
8671        WebUIProtocol::new(fragments)
8672    }
8673
8674    fn state_inject_options<'a>() -> RenderOptions<'a> {
8675        RenderOptions::new("index.html", "/")
8676    }
8677
8678    fn render_with(protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>) -> String {
8679        let mut writer = TestWriter::new();
8680        handle(protocol, state, options, &mut writer).unwrap();
8681        writer.get_content().to_string()
8682    }
8683
8684    #[test]
8685    fn state_inject_emits_at_every_structural_boundary() {
8686        let protocol = build_all_boundaries_protocol();
8687        let state = test_json!({
8688            "$webui": {
8689                "headEnd": "<meta name=he>",
8690                "bodyStart": "<span id=bs></span>",
8691                "bodyEnd": "<script>be</script>",
8692            }
8693        });
8694        let html = render_with(&protocol, &state, &state_inject_options());
8695
8696        let head_end = html.find("<meta name=he>").expect("headEnd missing");
8697        let head_close = html.find("</head>").expect("</head> missing");
8698        let body_open = html.find("<body>").expect("<body> missing");
8699        let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
8700        let hello = html.find("hello").expect("body content missing");
8701        let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
8702        let body_close = html.find("</body>").expect("</body> missing");
8703
8704        assert!(
8705            head_end < head_close,
8706            "headEnd must precede </head>: {html}"
8707        );
8708        assert!(
8709            body_open < body_start && body_start < hello,
8710            "bodyStart must sit immediately after <body>: {html}"
8711        );
8712        assert!(
8713            hello < body_end && body_end < body_close,
8714            "bodyEnd must precede </body>: {html}"
8715        );
8716
8717        for needle in [
8718            "<meta name=he>",
8719            "<span id=bs></span>",
8720            "<script>be</script>",
8721        ] {
8722            assert_eq!(html.matches(needle).count(), 1, "duplicated {needle}");
8723        }
8724    }
8725
8726    #[test]
8727    fn state_inject_follows_render_options_inject() {
8728        let protocol = build_all_boundaries_protocol();
8729        let state = test_json!({
8730            "$webui": { "headEnd": "<!--state-he-->", "bodyEnd": "<!--state-be-->" }
8731        });
8732        let options = RenderOptions::new("index.html", "/")
8733            .with_head_inject("<!--opt-he-->")
8734            .with_body_inject("<!--opt-be-->");
8735        let html = render_with(&protocol, &state, &options);
8736
8737        let opt_he = html.find("<!--opt-he-->").expect("option headEnd missing");
8738        let state_he = html.find("<!--state-he-->").expect("state headEnd missing");
8739        let opt_be = html.find("<!--opt-be-->").expect("option bodyEnd missing");
8740        let state_be = html.find("<!--state-be-->").expect("state bodyEnd missing");
8741
8742        assert!(
8743            opt_he < state_he,
8744            "RenderOptions head_inject must precede the state-supplied value: {html}"
8745        );
8746        assert!(
8747            opt_be < state_be,
8748            "RenderOptions body_inject must precede the state-supplied value: {html}"
8749        );
8750    }
8751
8752    #[test]
8753    fn malformed_state_inject_values_are_inert() {
8754        let protocol = build_all_boundaries_protocol();
8755        // Absent key, wrong container type, and per-member null / empty /
8756        // non-string values must all render without output and without error.
8757        for state in [
8758            test_json!({}),
8759            test_json!({ "$webui": "not-an-object" }),
8760            test_json!({ "$webui": [] }),
8761            test_json!({ "$webui": null }),
8762            test_json!({ "$webui": { "headEnd": null, "bodyStart": "", "bodyEnd": 42 } }),
8763            test_json!({ "$webui": { "unknownMember": "<b>x</b>" } }),
8764        ] {
8765            let html = render_with(&protocol, &state, &state_inject_options());
8766            assert!(
8767                html.contains("<html><head><title>x</title></head><body>hello</body></html>"),
8768                "malformed reserved state must render the document unchanged: {html}"
8769            );
8770            assert!(!html.contains("<b>x</b>"), "unknown member leaked: {html}");
8771            assert!(!html.contains("42"), "non-string member leaked: {html}");
8772        }
8773    }
8774
8775    #[test]
8776    fn state_inject_never_reaches_the_hydration_payload() {
8777        let protocol = build_all_boundaries_protocol();
8778        let state = test_json!({
8779            "visible": "keep",
8780            "$webui": { "bodyEnd": "<script>secret</script>" }
8781        });
8782        let handler = WebUIHandler::with_plugin(|| {
8783            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8784        });
8785        let runtime = Protocol::new(protocol.clone());
8786        let mut writer = TestWriter::new();
8787        handler
8788            .render(&runtime, &state, &state_inject_options(), &mut writer)
8789            .unwrap();
8790        let html = writer.get_content();
8791
8792        let data_start = html
8793            .find(r#"<script type="application/json" id="webui-data""#)
8794            .expect("hydration block missing");
8795        let data_end = html[data_start..]
8796            .find("</script>")
8797            .map(|offset| data_start + offset)
8798            .expect("hydration block never closes");
8799        let payload = &html[data_start..data_end];
8800        assert!(
8801            !payload.contains("$webui"),
8802            "reserved key must be stripped from the hydration payload: {payload}"
8803        );
8804        assert!(
8805            payload.contains("visible"),
8806            "ordinary state must survive the filter: {payload}"
8807        );
8808        // Per the documented precedence the injected HTML is emitted after
8809        // the built-in hydration block, and still before `</body>`.
8810        let inject = html
8811            .find("<script>secret</script>")
8812            .expect("inject missing");
8813        let body_close = html.find("</body>").expect("</body> missing");
8814        assert!(
8815            data_end < inject && inject < body_close,
8816            "state bodyEnd must follow the hydration block and precede </body>: {html}"
8817        );
8818    }
8819
8820    #[test]
8821    fn projected_hydration_strips_reserved_state_inject_key() {
8822        let mut fragments = HashMap::new();
8823        fragments.insert(
8824            "index.html".to_string(),
8825            FragmentList {
8826                fragments: vec![
8827                    WebUIFragment::raw("<html><body>"),
8828                    WebUIFragment::component("app-shell"),
8829                    structural_fragment("body_end"),
8830                    WebUIFragment::raw("</body></html>"),
8831                ],
8832            },
8833        );
8834        fragments.insert(
8835            "app-shell".to_string(),
8836            FragmentList {
8837                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
8838            },
8839        );
8840        let mut document = WebUIProtocol::new(fragments);
8841        document.initial_state_strategy = InitialStateStrategy::Components as i32;
8842        document.components.insert(
8843            "app-shell".to_string(),
8844            webui_protocol::ComponentData {
8845                hydration_mode: StateProjectionMode::Keys as i32,
8846                hydration_keys: vec![STATE_INJECT_KEY.to_string(), "visible".to_string()],
8847                ..Default::default()
8848            },
8849        );
8850        let state = test_json!({
8851            "$webui": { "bodyEnd": "<script>secret</script>" },
8852            "serverOnly": "drop",
8853            "visible": "keep",
8854        });
8855        let handler = WebUIHandler::with_plugin(|| {
8856            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8857        });
8858        let mut writer = TestWriter::new();
8859
8860        handler
8861            .render(
8862                &Protocol::new(document),
8863                &state,
8864                &RenderOptions::new("index.html", "/"),
8865                &mut writer,
8866            )
8867            .unwrap();
8868        let html = writer.get_content();
8869        let data_start = html
8870            .find(r#"<script type="application/json" id="webui-data""#)
8871            .expect("hydration block missing");
8872        let data_end = html[data_start..]
8873            .find("</script>")
8874            .map(|offset| data_start + offset)
8875            .expect("hydration block never closes");
8876        let payload = &html[data_start..data_end];
8877
8878        assert!(
8879            !payload.contains(STATE_INJECT_KEY),
8880            "reserved key leaked: {payload}"
8881        );
8882        assert!(payload.contains(r#""visible":"keep""#), "{payload}");
8883        assert!(!payload.contains("serverOnly"), "{payload}");
8884        assert!(html.contains("<script>secret</script>"), "{html}");
8885    }
8886
8887    #[test]
8888    fn write_selected_state_strips_reserved_key_from_full_state() {
8889        let state = test_json!({ "a": 1, "$webui": { "bodyEnd": "<b>x</b>" }, "z": 2 });
8890        let mut sink = TestWriter::new();
8891        let mut scratch = Vec::new();
8892        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
8893        let json = sink.get_content();
8894        assert!(!json.contains("$webui"), "reserved key leaked: {json}");
8895        assert!(
8896            json.contains("\"a\":1") && json.contains("\"z\":2"),
8897            "{json}"
8898        );
8899    }
8900
8901    #[test]
8902    fn write_selected_state_strips_reserved_key_from_borrowed_projection() {
8903        let state = test_json!({
8904            "$webui": { "bodyEnd": "<b>x</b>" },
8905            "keep": 1,
8906        });
8907        let keys = [STATE_INJECT_KEY, "keep", "missing"];
8908        let mut sink = TestWriter::new();
8909        let mut scratch = Vec::new();
8910
8911        write_selected_state(
8912            &mut sink,
8913            &mut scratch,
8914            &state,
8915            &StateSelection::BorrowedKeys(&keys),
8916        )
8917        .unwrap();
8918
8919        assert_eq!(sink.get_content(), r#"{"keep":1}"#);
8920    }
8921
8922    #[test]
8923    fn write_selected_state_full_is_unchanged_without_reserved_key() {
8924        let state = test_json!({ "a": 1, "z": 2 });
8925        let mut sink = TestWriter::new();
8926        let mut scratch = Vec::new();
8927        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
8928        assert_eq!(sink.get_content(), r#"{"a":1,"z":2}"#);
8929    }
8930
8931    #[test]
8932    fn body_start_hook_dedupes_on_malformed_protocol() {
8933        let mut fragments = HashMap::new();
8934        fragments.insert(
8935            "index.html".to_string(),
8936            FragmentList {
8937                fragments: vec![
8938                    WebUIFragment::raw("<html><head></head><body>".to_string()),
8939                    structural_fragment("body_start"),
8940                    structural_fragment("body_start"),
8941                    WebUIFragment::raw("</body></html>".to_string()),
8942                ],
8943            },
8944        );
8945        let protocol = WebUIProtocol::new(fragments);
8946        let state = test_json!({ "$webui": { "bodyStart": "<i>once</i>" } });
8947        let html = render_with(&protocol, &state, &state_inject_options());
8948        assert_eq!(
8949            html.matches("<i>once</i>").count(),
8950            1,
8951            "duplicate body_start signals must not duplicate the inject: {html}"
8952        );
8953    }
8954
8955    #[test]
8956    fn injects_are_no_op_when_unset() {
8957        let protocol = build_head_body_protocol();
8958        let state = test_json!({});
8959        let mut writer = TestWriter::new();
8960        handle(
8961            &protocol,
8962            &state,
8963            &RenderOptions::new("index.html", "/"),
8964            &mut writer,
8965        )
8966        .unwrap();
8967        let html = writer.get_content();
8968        assert!(!html.contains("<link rel=preload>"));
8969        assert!(!html.contains("<script>lr</script>"));
8970    }
8971
8972    #[test]
8973    fn empty_inject_string_treated_as_unset() {
8974        let protocol = build_head_body_protocol();
8975        let state = test_json!({});
8976        let mut writer = TestWriter::new();
8977        let opts = RenderOptions::new("index.html", "/")
8978            .with_head_inject("")
8979            .with_body_inject("");
8980        handle(&protocol, &state, &opts, &mut writer).unwrap();
8981        // No injection happens — empty strings are normalised to None
8982        // by the builder, so the output is identical to the no-options case.
8983        let html = writer.get_content();
8984        assert!(html.contains("</head>"));
8985        assert!(html.contains("</body>"));
8986    }
8987
8988    #[test]
8989    fn inject_html_is_passed_through_verbatim() {
8990        // The handler does NOT escape the inject string — hosts pass
8991        // raw HTML they trust. This test pins that contract: a `<` in
8992        // the inject is emitted as-is, not encoded as `&lt;`.
8993        let protocol = build_head_body_protocol();
8994        let state = test_json!({});
8995        let mut writer = TestWriter::new();
8996        let opts =
8997            RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
8998        handle(&protocol, &state, &opts, &mut writer).unwrap();
8999        assert!(writer.get_content().contains("<script>var x=1;</script>"));
9000    }
9001
9002    /// Both injects fire and appear at the correct structural
9003    /// positions. Critically, this is robust against `</head>` /
9004    /// `</body>` literals appearing elsewhere in the document — the
9005    /// signal-based emitter cannot mis-fire on byte patterns inside
9006    /// HTML comments, `<iframe srcdoc>`, or inline scripts (which the
9007    /// previous byte-scanner could).
9008    #[test]
9009    fn injects_robust_against_marker_literals_in_content() {
9010        let mut fragments = HashMap::new();
9011        // The body intentionally contains `</body>` and `</head>`
9012        // literals before the actual structural close — these came
9013        // from a (hypothetical) iframe srcdoc or comment.
9014        fragments.insert(
9015            "index.html".to_string(),
9016            FragmentList {
9017                fragments: vec![
9018                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
9019                    structural_fragment("head_end"),
9020                    WebUIFragment::raw(
9021                        "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
9022                    ),
9023                    structural_fragment("body_end"),
9024                    WebUIFragment::raw("</body></html>".to_string()),
9025                ],
9026            },
9027        );
9028        let protocol = WebUIProtocol::new(fragments);
9029        let state = test_json!({});
9030        let mut writer = TestWriter::new();
9031        let opts = RenderOptions::new("index.html", "/")
9032            .with_head_inject("<HEAD-INJ>")
9033            .with_body_inject("<BODY-INJ>");
9034        handle(&protocol, &state, &opts, &mut writer).unwrap();
9035        let html = writer.get_content();
9036        // The head inject sits between `<title>x</title>` and the
9037        // first `</head>` — the structural one, not the comment one.
9038        let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
9039        let head_close_idx = html.find("</head>").expect("</head> missing");
9040        assert!(head_inj_idx < head_close_idx);
9041        // The body inject sits before the structural `</body>` — NOT
9042        // before the `</body>` literal in the comment (which would
9043        // require the inject to appear inside `<p>hi</p>` somewhere).
9044        let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
9045        // Find the LAST `</body>` (the structural one).
9046        let body_close_idx = html.rfind("</body>").expect("</body> missing");
9047        assert!(
9048            body_inj_idx < body_close_idx,
9049            "body_inject must precede the structural </body>: {html}"
9050        );
9051        // And the comment is preserved verbatim.
9052        assert!(html.contains("<!-- </body> </head> -->"));
9053    }
9054
9055    /// Coverage-14: both `head_inject` AND `body_inject` set in the
9056    /// same render. Each fires at the correct structural boundary and
9057    /// neither leaks into the other's region.
9058    #[test]
9059    fn both_injects_fire_at_correct_boundaries() {
9060        let protocol = build_head_body_protocol();
9061        let state = test_json!({});
9062        let mut writer = TestWriter::new();
9063        let opts = RenderOptions::new("index.html", "/")
9064            .with_head_inject("<META-HEAD>")
9065            .with_body_inject("<SCRIPT-BODY>");
9066        handle(&protocol, &state, &opts, &mut writer).unwrap();
9067        let html = writer.get_content();
9068        let head_idx = html.find("<META-HEAD>").expect("head inject missing");
9069        let head_close = html.find("</head>").expect("</head> missing");
9070        let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
9071        let body_close = html.find("</body>").expect("</body> missing");
9072        assert!(head_idx < head_close, "head_inject before </head>");
9073        assert!(head_close < body_idx, "body_inject after </head>");
9074        assert!(body_idx < body_close, "body_inject before </body>");
9075        assert_eq!(html.matches("<META-HEAD>").count(), 1);
9076        assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
9077    }
9078
9079    /// Coverage-15 / Bug-3 (security defense): a malformed protocol
9080    /// emitting `head_end` and `body_end` more than once must NOT
9081    /// duplicate the host inject HTML. Without the dedup guard,
9082    /// double-emission would amplify Security-2 (a 1 MiB inject ×
9083    /// 1000 duplicate signals = 1 GiB output).
9084    #[test]
9085    fn injects_dedupe_against_duplicate_signals() {
9086        let mut fragments = HashMap::new();
9087        fragments.insert(
9088            "index.html".to_string(),
9089            FragmentList {
9090                fragments: vec![
9091                    WebUIFragment::raw("<html><head>".to_string()),
9092                    structural_fragment("head_end"),
9093                    structural_fragment("head_end"), // duplicate
9094                    structural_fragment("head_end"), // triplicate
9095                    WebUIFragment::raw("</head><body>".to_string()),
9096                    structural_fragment("body_end"),
9097                    structural_fragment("body_end"), // duplicate
9098                    WebUIFragment::raw("</body></html>".to_string()),
9099                ],
9100            },
9101        );
9102        let protocol = WebUIProtocol::new(fragments);
9103        let state = test_json!({});
9104        let mut writer = TestWriter::new();
9105        let opts = RenderOptions::new("index.html", "/")
9106            .with_head_inject("<HINJ>")
9107            .with_body_inject("<BINJ>");
9108        handle(&protocol, &state, &opts, &mut writer).unwrap();
9109        let html = writer.get_content();
9110        assert_eq!(
9111            html.matches("<HINJ>").count(),
9112            1,
9113            "head_inject must emit exactly once even with duplicate head_end signals"
9114        );
9115        assert_eq!(
9116            html.matches("<BINJ>").count(),
9117            1,
9118            "body_inject must emit exactly once even with duplicate body_end signals"
9119        );
9120    }
9121
9122    /// Coverage-15: a Shadow-DOM / component-only protocol that has NO
9123    /// `<head>` / `<body>` tags must NOT emit the inject (the signals
9124    /// never fire). Verifies the injects are no-ops, not panics.
9125    #[test]
9126    fn injects_no_op_when_no_head_or_body_signals() {
9127        let mut fragments = HashMap::new();
9128        fragments.insert(
9129            "index.html".to_string(),
9130            FragmentList {
9131                fragments: vec![WebUIFragment::raw(
9132                    "<my-component>hi</my-component>".to_string(),
9133                )],
9134            },
9135        );
9136        let protocol = WebUIProtocol::new(fragments);
9137        let state = test_json!({});
9138        let mut writer = TestWriter::new();
9139        let opts = RenderOptions::new("index.html", "/")
9140            .with_head_inject("<HINJ>")
9141            .with_body_inject("<BINJ>");
9142        handle(&protocol, &state, &opts, &mut writer).unwrap();
9143        let html = writer.get_content();
9144        assert!(!html.contains("<HINJ>"), "head_inject must not appear");
9145        assert!(!html.contains("<BINJ>"), "body_inject must not appear");
9146        assert!(html.contains("<my-component>"));
9147    }
9148
9149    /// Coverage-19: the handler's `&self` is shared across threads.
9150    /// Two concurrent renders with different inject values must NOT
9151    /// cross-contaminate (each thread sees only its own inject).
9152    /// Per-render mutable state lives on the `WebUIProcessContext`,
9153    /// which is stack-allocated per call.
9154    #[test]
9155    fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
9156        let protocol = std::sync::Arc::new(build_head_body_protocol());
9157        let state = std::sync::Arc::new(test_json!({}));
9158        let handler = std::sync::Arc::new(WebUIHandler::new());
9159
9160        const N_THREADS: usize = 16;
9161        let mut handles = Vec::with_capacity(N_THREADS);
9162        for tid in 0..N_THREADS {
9163            let h = std::sync::Arc::clone(&handler);
9164            let p = std::sync::Arc::clone(&protocol);
9165            let s = std::sync::Arc::clone(&state);
9166            handles.push(std::thread::spawn(move || {
9167                let head = format!("<HEAD-T{tid}>");
9168                let body = format!("<BODY-T{tid}>");
9169                let mut writer = TestWriter::new();
9170                let opts = RenderOptions::new("index.html", "/")
9171                    .with_head_inject(&head)
9172                    .with_body_inject(&body);
9173                h.handle(&p, &s, &opts, &mut writer).unwrap();
9174                let html = writer.get_content();
9175                // Must contain my own injects exactly once.
9176                assert_eq!(html.matches(&head).count(), 1);
9177                assert_eq!(html.matches(&body).count(), 1);
9178                // Must NOT contain any other thread's inject.
9179                for other in 0..N_THREADS {
9180                    if other == tid {
9181                        continue;
9182                    }
9183                    let other_head = format!("<HEAD-T{other}>");
9184                    let other_body = format!("<BODY-T{other}>");
9185                    assert!(
9186                        !html.contains(&other_head),
9187                        "tid {tid} saw {other}'s head_inject"
9188                    );
9189                    assert!(
9190                        !html.contains(&other_body),
9191                        "tid {tid} saw {other}'s body_inject"
9192                    );
9193                }
9194            }));
9195        }
9196        for h in handles {
9197            h.join().expect("worker panicked");
9198        }
9199    }
9200
9201    /// Coverage-17: a large (1 MiB) head_inject must round-trip
9202    /// correctly without panic, truncation, or excessive overhead.
9203    /// (No size cap is enforced by the handler — the host owns the
9204    /// safety contract; see `with_head_inject` doc comment.)
9205    #[test]
9206    fn large_inject_roundtrips_without_truncation() {
9207        let protocol = build_head_body_protocol();
9208        let state = test_json!({});
9209        let mut writer = TestWriter::new();
9210        let big = "x".repeat(1024 * 1024);
9211        let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
9212        handle(&protocol, &state, &opts, &mut writer).unwrap();
9213        let html = writer.get_content();
9214        assert!(
9215            html.contains(&big),
9216            "large head_inject must be present verbatim ({} bytes)",
9217            big.len()
9218        );
9219        // Sanity: only one copy.
9220        assert_eq!(html.matches(&big).count(), 1);
9221    }
9222
9223    /// `with_nonce("")` must normalize to `None` (no `<meta>` emitted),
9224    /// matching the empty-string semantics of `with_head_inject` /
9225    /// `with_body_inject`. An empty content attribute is browser-
9226    /// ignored noise.
9227    #[test]
9228    fn empty_nonce_treated_as_unset() {
9229        let protocol = build_head_body_protocol();
9230        let state = test_json!({});
9231        let mut writer = TestWriter::new();
9232        let opts = RenderOptions::new("index.html", "/").with_nonce("");
9233        handle(&protocol, &state, &opts, &mut writer).unwrap();
9234        assert!(
9235            !writer.get_content().contains("webui-nonce"),
9236            "empty nonce must not emit <meta name=\"webui-nonce\">"
9237        );
9238    }
9239
9240    /// Regression for the bug Akrosh caught: the `pub` fields on
9241    /// `RenderOptions` let a caller bypass the `with_*` builder
9242    /// normalisation, e.g.:
9243    ///
9244    /// ```ignore
9245    /// RenderOptions { nonce: Some(""), ..RenderOptions::new(e, p) }
9246    /// ```
9247    ///
9248    /// Without defensive normalisation at handler init, this would
9249    /// emit `<script nonce="">` on every inline script. Under a
9250    /// strict `Content-Security-Policy: script-src 'nonce-...'` an
9251    /// empty nonce is a HARD CSP failure that blocks every inline
9252    /// script — a complete inline-script-execution outage.
9253    ///
9254    /// The handler now treats `Some("")` identically to `None` for
9255    /// all three injection points (nonce / head_inject / body_inject)
9256    /// regardless of how the option was populated.
9257    #[test]
9258    fn empty_field_bypass_is_normalised_at_handler_init() {
9259        let protocol = build_head_body_protocol();
9260        let state = test_json!({});
9261
9262        // Bypass the `with_nonce` builder by writing the field directly.
9263        let opts_with_empty_nonce = RenderOptions {
9264            nonce: Some(""),
9265            ..RenderOptions::new("index.html", "/")
9266        };
9267        let mut writer = TestWriter::new();
9268        handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
9269        let html = writer.get_content();
9270        assert!(
9271            !html.contains("webui-nonce"),
9272            "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
9273        );
9274        assert!(
9275            !html.contains("nonce=\"\""),
9276            "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
9277        );
9278
9279        // Same defence for inject fields.
9280        let opts_with_empty_injects = RenderOptions {
9281            head_inject: Some(""),
9282            body_inject: Some(""),
9283            ..RenderOptions::new("index.html", "/")
9284        };
9285        let mut writer = TestWriter::new();
9286        handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
9287        // No assertion needed beyond "doesn't panic and doesn't emit
9288        // empty inject markers" — the head_end / body_end paths must
9289        // treat the empty inject as no-op the same way the builder does.
9290    }
9291
9292    /// Regression for the deep-audit's Bug-6 claim. The for-loop hot-
9293    /// path optimisation (insert key once + `get_mut`-swap value
9294    /// in-place) was suspected of corrupting the outer scope when a
9295    /// nested `<for>` loop reuses the same variable name. This test
9296    /// proves the optimisation is correct under that condition by
9297    /// requiring the outer `item` to be visible before, between, and
9298    /// after the inner loop, with its value preserved across inner
9299    /// iterations.
9300    ///
9301    /// Trace through the optimisation on `outer = [A, B]`,
9302    /// `inner = [X, Y]` with both loops using `item` as the variable:
9303    ///
9304    ///   outer pre-insert "item": Null
9305    ///   iter 1: get_mut → write A
9306    ///     emit "outer:A"
9307    ///     enter inner: saved = remove("item") = Some(A)
9308    ///                  pre-insert "item": Null
9309    ///                  iter 1: write X → emit "inner:X"
9310    ///                  iter 2: write Y → emit "inner:Y"
9311    ///                  restore: insert("item", A)   ← outer's A back
9312    ///     emit "outer:A again"               ← reads A correctly
9313    ///   iter 2: get_mut → write B (overwrites the restored A,
9314    ///                              but that's correct — we're now
9315    ///                              in iter 2 of the outer loop)
9316    ///     emit "outer:B"
9317    ///     enter inner: saved = remove("item") = Some(B), …, restore B
9318    ///     emit "outer:B again"
9319    ///
9320    /// If the audit's claim were correct — that the outer's `get_mut`
9321    /// somehow held a reference past the inner loop and clobbered the
9322    /// restoration — we'd see corrupted values in the "outer:X again"
9323    /// emissions. The assertion below pins the correct sequence.
9324    #[test]
9325    fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
9326        let mut fragments = HashMap::new();
9327        fragments.insert(
9328            "index.html".to_string(),
9329            FragmentList {
9330                fragments: vec![
9331                    WebUIFragment::raw("["),
9332                    WebUIFragment::for_loop("item", "outer", "outer_body"),
9333                    WebUIFragment::raw("]"),
9334                ],
9335            },
9336        );
9337        fragments.insert(
9338            "outer_body".to_string(),
9339            FragmentList {
9340                fragments: vec![
9341                    WebUIFragment::raw("(O="),
9342                    WebUIFragment::signal("item.tag", false),
9343                    WebUIFragment::for_loop("item", "inner", "inner_body"),
9344                    WebUIFragment::raw(",O="),
9345                    WebUIFragment::signal("item.tag", false),
9346                    WebUIFragment::raw(")"),
9347                ],
9348            },
9349        );
9350        fragments.insert(
9351            "inner_body".to_string(),
9352            FragmentList {
9353                fragments: vec![
9354                    WebUIFragment::raw("[I="),
9355                    WebUIFragment::signal("item.tag", false),
9356                    WebUIFragment::raw("]"),
9357                ],
9358            },
9359        );
9360        let protocol = WebUIProtocol::new(fragments);
9361        let state = test_json!({
9362            "outer": [{"tag": "A"}, {"tag": "B"}],
9363            "inner": [{"tag": "X"}, {"tag": "Y"}],
9364        });
9365        let mut writer = TestWriter::new();
9366        handle(
9367            &protocol,
9368            &state,
9369            &RenderOptions::new("index.html", "/"),
9370            &mut writer,
9371        )
9372        .unwrap();
9373        // Expected sequence:
9374        //   outer iter 1 (item=A):
9375        //     emit "(O=A"               ← outer A before inner
9376        //     inner iter 1 (item=X) emit "[I=X]"
9377        //     inner iter 2 (item=Y) emit "[I=Y]"
9378        //     emit ",O=A)"              ← outer A AFTER inner restore
9379        //   outer iter 2 (item=B):
9380        //     emit "(O=B"
9381        //     inner iter 1 (item=X) emit "[I=X]"
9382        //     inner iter 2 (item=Y) emit "[I=Y]"
9383        //     emit ",O=B)"
9384        assert_eq!(
9385            writer.get_content(),
9386            "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
9387            "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
9388        );
9389    }
9390
9391    #[derive(Default)]
9392    struct FlushTestWriter {
9393        output: String,
9394        flushes: Vec<usize>,
9395        fail_flush: bool,
9396        fail_flush_at: Option<usize>,
9397        flush_attempts: usize,
9398        ended: bool,
9399    }
9400
9401    impl ResponseWriter for FlushTestWriter {
9402        fn write(&mut self, content: &str) -> Result<()> {
9403            self.output.push_str(content);
9404            Ok(())
9405        }
9406
9407        fn end(&mut self) -> Result<()> {
9408            self.ended = true;
9409            Ok(())
9410        }
9411    }
9412
9413    impl FlushWriter for FlushTestWriter {
9414        fn flush(&mut self) -> Result<()> {
9415            let attempt = self.flush_attempts;
9416            self.flush_attempts += 1;
9417            if self.fail_flush || self.fail_flush_at == Some(attempt) {
9418                return Err(HandlerError::ClientDisconnected);
9419            }
9420            self.flushes.push(self.output.len());
9421            Ok(())
9422        }
9423    }
9424
9425    fn streaming_protocol(with_boundaries: bool) -> Protocol {
9426        streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
9427    }
9428
9429    fn streaming_protocol_with_state_strategy(
9430        with_boundaries: bool,
9431        state_strategy: InitialStateStrategy,
9432    ) -> Protocol {
9433        let mut fragments = HashMap::new();
9434        let mut entry = vec![
9435            WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
9436            structural_fragment("head_start"),
9437            WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
9438            structural_fragment("head_end"),
9439            WebUIFragment::raw("</HEAD><body>"),
9440            structural_fragment("body_start"),
9441        ];
9442        if with_boundaries {
9443            entry.push(structural_fragment("boundary_start:0"));
9444        }
9445        entry.extend([
9446            WebUIFragment::raw("<my-counter"),
9447            structural_fragment("streaming_root:my-counter"),
9448            WebUIFragment::raw(">"),
9449            WebUIFragment::component("my-counter"),
9450            WebUIFragment::raw("</my-counter>"),
9451        ]);
9452        if with_boundaries {
9453            entry.push(structural_fragment("boundary_end:0"));
9454        }
9455        entry.extend([
9456            WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
9457            structural_fragment("body_end"),
9458            WebUIFragment::raw("</body></html>"),
9459        ]);
9460        fragments.insert("index.html".to_string(), FragmentList { fragments: entry });
9461        fragments.insert(
9462            "my-counter".to_string(),
9463            FragmentList {
9464                fragments: vec![WebUIFragment::raw("<button>Count</button>")],
9465            },
9466        );
9467
9468        let mut document = WebUIProtocol::new(fragments);
9469        document.initial_state_strategy = state_strategy as i32;
9470        document.components.insert(
9471            "my-counter".to_string(),
9472            webui_protocol::ComponentData {
9473                template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
9474                template_functions: "[function(){return true}]".to_string(),
9475                hydration_mode: StateProjectionMode::Keys as i32,
9476                hydration_keys: vec!["count".to_string()],
9477                ..Default::default()
9478            },
9479        );
9480        Protocol::new(document)
9481    }
9482
9483    #[test]
9484    fn handler_error_stays_small() {
9485        // Boxing the cold `StreamingBoundary` payload keeps `HandlerError` — and
9486        // therefore `Result<(), HandlerError>` threaded through the hot legacy
9487        // render path — down to a single `String`-sized payload plus a
9488        // discriminant word. If the boundary payload is un-boxed back to
9489        // `{ signal, reason }` it grows to two `String`s (48-byte payload) and
9490        // this fails.
9491        assert!(
9492            std::mem::size_of::<HandlerError>()
9493                <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
9494            "HandlerError grew to {} bytes",
9495            std::mem::size_of::<HandlerError>()
9496        );
9497    }
9498
9499    #[test]
9500    fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
9501        let protocol = streaming_protocol(true);
9502        let handler = WebUIHandler::with_plugin(|| {
9503            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9504        });
9505        let state = test_json!({ "count": 1, "serverOnly": "secret" });
9506        let mut writer = FlushTestWriter::default();
9507
9508        handler
9509            .render_streaming(
9510                &protocol,
9511                &state,
9512                &RenderOptions::new("index.html", "/"),
9513                &mut writer,
9514            )
9515            .unwrap();
9516
9517        assert!(writer.ended);
9518        assert_eq!(
9519            writer.flushes.len(),
9520            2,
9521            "boundary commit plus one coalesced terminal-tail flush"
9522        );
9523        let first_flush = &writer.output[..writer.flushes[0]];
9524        assert!(first_flush.contains("<!--wb:0-->"));
9525        assert!(first_flush.contains("<!--/wb:0-->"));
9526        assert!(first_flush.contains(r#"[1,0,0,0,{"inventory":"01","state":{"count":1}"#));
9527        assert!(first_flush.contains(r#""templates":{"my-counter":"#));
9528        assert!(!first_flush.contains("slow tail"));
9529        assert!(!writer.output.contains("id=\"webui-data\""));
9530        // The terminal flush commits the scriptless tail without manufacturing
9531        // another state/template projection.
9532        assert!(writer.output.contains("[1,1,3,0,{}]"));
9533        assert!(!writer.output.contains("[1,1,0,1,"));
9534        assert!(!writer.output.contains("[1,2,3,0,{}]"));
9535
9536        let marker = writer
9537            .output
9538            .find(STREAMING_MARKER)
9539            .expect("streaming marker");
9540        let attributed_head = writer
9541            .output
9542            .find("<HEAD data-shell=\"main\">")
9543            .expect("attributed mixed-case head");
9544        let authored_script = writer
9545            .output
9546            .find("src=\"/index.js\"")
9547            .expect("entry script");
9548        assert!(attributed_head < marker && marker < authored_script);
9549    }
9550
9551    #[test]
9552    fn streaming_terminal_tail_never_resends_full_state() {
9553        let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
9554        let handler = WebUIHandler::with_plugin(|| {
9555            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9556        });
9557        let mut writer = FlushTestWriter::default();
9558
9559        handler
9560            .render_streaming(
9561                &protocol,
9562                &test_json!({ "count": 1, "serverOnly": "secret" }),
9563                &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
9564                &mut writer,
9565            )
9566            .unwrap();
9567
9568        assert_eq!(
9569            writer.output.matches("serverOnly").count(),
9570            1,
9571            "full state belongs only to the interactive boundary"
9572        );
9573        assert!(writer.output.contains("[1,1,3,0,{}]"));
9574    }
9575
9576    /// Streaming must place the reserved-state injects exactly where the
9577    /// ordinary render does, so a host can switch modes without its
9578    /// boundary HTML moving.
9579    #[test]
9580    fn state_inject_placement_matches_between_render_modes() {
9581        let entry = vec![
9582            WebUIFragment::raw("<html><head>"),
9583            structural_fragment("head_start"),
9584            structural_fragment("head_end"),
9585            WebUIFragment::raw("</head><body>"),
9586            structural_fragment("body_start"),
9587            WebUIFragment::raw("<main>static</main>"),
9588            structural_fragment("body_end"),
9589            WebUIFragment::raw("</body></html>"),
9590        ];
9591        let fragments =
9592            HashMap::from([("index.html".to_string(), FragmentList { fragments: entry })]);
9593        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9594        let state = test_json!({
9595            "$webui": {
9596                "headEnd": "<meta name=he>",
9597                "bodyStart": "<span id=bs></span>",
9598                "bodyEnd": "<script>be</script>",
9599            }
9600        });
9601        let options = RenderOptions::new("index.html", "/");
9602
9603        let mut ordinary = TestWriter::new();
9604        WebUIHandler::new()
9605            .render(&protocol, &state, &options, &mut ordinary)
9606            .unwrap();
9607        let ordinary_html = ordinary.get_content().to_string();
9608
9609        let mut streamed = FlushTestWriter::default();
9610        WebUIHandler::new()
9611            .render_streaming(&protocol, &state, &options, &mut streamed)
9612            .unwrap();
9613        let streamed_html = &streamed.output;
9614
9615        for html in [ordinary_html.as_str(), streamed_html.as_str()] {
9616            let head_end = html.find("<meta name=he>").expect("headEnd missing");
9617            let head_close = html.find("</head>").expect("</head> missing");
9618            let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
9619            let main = html.find("<main>static</main>").expect("content missing");
9620            let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
9621            let body_close = html.find("</body>").expect("</body> missing");
9622            assert!(head_end < head_close, "headEnd misplaced: {html}");
9623            assert!(body_start < main, "bodyStart misplaced: {html}");
9624            assert!(
9625                main < body_end && body_end < body_close,
9626                "bodyEnd misplaced: {html}"
9627            );
9628        }
9629
9630        // The streaming response still terminates with its single empty
9631        // terminal record: an inject must not perturb the record stream.
9632        assert!(
9633            streamed_html.contains(",3,0,{}]"),
9634            "streaming must still end in one empty terminal record: {streamed_html}"
9635        );
9636    }
9637
9638    #[test]
9639    fn streaming_state_inject_emits_and_strips_reserved_key() {
9640        let entry = vec![
9641            WebUIFragment::raw("<html><head>"),
9642            structural_fragment("head_start"),
9643            structural_fragment("head_end"),
9644            WebUIFragment::raw("</head><body>"),
9645            structural_fragment("body_start"),
9646            structural_fragment("body_end"),
9647            WebUIFragment::raw("</body></html>"),
9648        ];
9649        let fragments =
9650            HashMap::from([("index.html".to_string(), FragmentList { fragments: entry })]);
9651        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9652        let state = test_json!({ "$webui": { "bodyEnd": "<script>be</script>" } });
9653
9654        let mut writer = FlushTestWriter::default();
9655        WebUIHandler::new()
9656            .render_streaming(
9657                &protocol,
9658                &state,
9659                &RenderOptions::new("index.html", "/"),
9660                &mut writer,
9661            )
9662            .unwrap();
9663        assert!(writer.output.contains("<script>be</script>"));
9664        assert!(!writer.output.contains("$webui"));
9665    }
9666
9667    #[test]
9668    fn static_streaming_document_uses_one_empty_terminal_record() {
9669        let fragments = HashMap::from([(
9670            "index.html".to_string(),
9671            FragmentList {
9672                fragments: vec![
9673                    WebUIFragment::raw("<html><head>"),
9674                    structural_fragment("head_start"),
9675                    structural_fragment("head_end"),
9676                    WebUIFragment::raw("</head><body>"),
9677                    structural_fragment("body_start"),
9678                    WebUIFragment::raw("<main>static</main>"),
9679                    structural_fragment("body_end"),
9680                    WebUIFragment::raw("</body></html>"),
9681                ],
9682            },
9683        )]);
9684        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9685        let mut writer = FlushTestWriter::default();
9686
9687        WebUIHandler::new()
9688            .render_streaming(
9689                &protocol,
9690                &test_json!({ "serverOnly": "secret" }),
9691                &RenderOptions::new("index.html", "/"),
9692                &mut writer,
9693            )
9694            .unwrap();
9695
9696        assert_eq!(writer.flushes.len(), 1);
9697        assert!(writer.output.contains(STREAMING_MARKER));
9698        assert!(writer.output.contains("[1,0,3,0,{}]"));
9699        assert!(!writer.output.contains("serverOnly"));
9700        assert!(!writer.output.contains("id=\"webui-data\""));
9701        assert!(!writer.output.contains("<!--wb:"));
9702    }
9703
9704    #[test]
9705    fn streaming_render_emits_nonce_and_functions_before_sentinel() {
9706        let protocol = streaming_protocol(true);
9707        let handler = WebUIHandler::with_plugin(|| {
9708            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9709        });
9710        let mut writer = FlushTestWriter::default();
9711        handler
9712            .render_streaming(
9713                &protocol,
9714                &test_json!({ "count": 1 }),
9715                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
9716                &mut writer,
9717            )
9718            .unwrap();
9719
9720        let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
9721        let envelope = writer.output[boundary_end..]
9722            .find("data-webui-boundary nonce=\"test-nonce-123\"")
9723            .map(|index| index + boundary_end)
9724            .expect("nonce-bearing envelope");
9725        let functions = writer
9726            .output
9727            .find("templateFns")
9728            .expect("function side channel");
9729        let sentinel = writer
9730            .output
9731            .find("<webui-hydrate>")
9732            .expect("hydration sentinel");
9733        assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
9734        assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
9735    }
9736
9737    #[test]
9738    fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
9739        fn protocol(signals: &[&str]) -> Protocol {
9740            let mut protocol_signals = Vec::with_capacity(signals.len() + 1);
9741            protocol_signals.push(structural_fragment("head_start"));
9742            protocol_signals.extend(signals.iter().map(|signal| structural_fragment(*signal)));
9743            let fragments = HashMap::from([(
9744                "index.html".to_string(),
9745                FragmentList {
9746                    fragments: protocol_signals,
9747                },
9748            )]);
9749            Protocol::new(WebUIProtocol::new(fragments))
9750        }
9751
9752        let handler = WebUIHandler::new();
9753        let mut writer = FlushTestWriter::default();
9754        let result = handler.render_streaming(
9755            &protocol(&["boundary_start:1"]),
9756            &test_json!({}),
9757            &RenderOptions::new("index.html", "/"),
9758            &mut writer,
9759        );
9760        match result {
9761            Err(HandlerError::StreamingBoundary(err)) => {
9762                assert_eq!(err.signal, "boundary_start:1");
9763                assert!(err.reason.contains("expected boundary sequence 0"));
9764            }
9765            other => panic!("expected ordered-boundary error, got {other:?}"),
9766        }
9767
9768        let mut writer = FlushTestWriter::default();
9769        let result = handler.render_streaming(
9770            &protocol(&["boundary_start:0", "boundary_start:1"]),
9771            &test_json!({}),
9772            &RenderOptions::new("index.html", "/"),
9773            &mut writer,
9774        );
9775        assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
9776    }
9777
9778    #[test]
9779    fn streaming_render_propagates_boundary_flush_disconnect() {
9780        let protocol = streaming_protocol(true);
9781        let handler = WebUIHandler::new();
9782        let mut writer = FlushTestWriter {
9783            fail_flush: true,
9784            ..FlushTestWriter::default()
9785        };
9786        let result = handler.render_streaming(
9787            &protocol,
9788            &test_json!({}),
9789            &RenderOptions::new("index.html", "/"),
9790            &mut writer,
9791        );
9792        assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
9793        assert!(!writer.ended);
9794    }
9795
9796    #[test]
9797    fn streaming_render_requires_structural_head_start_before_writing() {
9798        let fragments = HashMap::from([(
9799            "index.html".to_string(),
9800            FragmentList {
9801                fragments: vec![
9802                    WebUIFragment::raw(
9803                        "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
9804                    ),
9805                    structural_fragment("head_end"),
9806                    WebUIFragment::raw("</HEAD><body>"),
9807                    structural_fragment("body_start"),
9808                    structural_fragment("body_end"),
9809                ],
9810            },
9811        )]);
9812        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9813        let mut writer = FlushTestWriter::default();
9814
9815        let result = WebUIHandler::new().render_streaming(
9816            &protocol,
9817            &test_json!({}),
9818            &RenderOptions::new("index.html", "/"),
9819            &mut writer,
9820        );
9821
9822        assert!(matches!(
9823            result,
9824            Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
9825        ));
9826        assert!(
9827            writer.output.is_empty(),
9828            "preflight must fail before output"
9829        );
9830        assert!(writer.flushes.is_empty());
9831        assert!(!writer.ended);
9832    }
9833
9834    #[test]
9835    fn streaming_render_rejects_duplicate_head_start() {
9836        let fragments = HashMap::from([(
9837            "index.html".to_string(),
9838            FragmentList {
9839                fragments: vec![
9840                    WebUIFragment::raw("<html><head>"),
9841                    structural_fragment("head_start"),
9842                    structural_fragment("head_start"),
9843                    structural_fragment("head_end"),
9844                    WebUIFragment::raw("</head><body>"),
9845                    structural_fragment("body_start"),
9846                    structural_fragment("body_end"),
9847                ],
9848            },
9849        )]);
9850        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9851        let mut writer = FlushTestWriter::default();
9852
9853        let result = WebUIHandler::new().render_streaming(
9854            &protocol,
9855            &test_json!({}),
9856            &RenderOptions::new("index.html", "/"),
9857            &mut writer,
9858        );
9859
9860        assert!(matches!(
9861            result,
9862            Err(HandlerError::DuplicateStreamingHeadStart)
9863        ));
9864        assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
9865        assert!(writer.flushes.is_empty());
9866    }
9867
9868    #[test]
9869    fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
9870        let handler = WebUIHandler::with_plugin(|| {
9871            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9872        });
9873        let state = test_json!({
9874            "count": 1,
9875            "head_start": "must not render",
9876            "boundary_start:0": "must not render",
9877            "boundary_end:0": "must not render",
9878        });
9879        let mut with_boundaries = TestWriter::new();
9880        let mut without_boundaries = TestWriter::new();
9881        handler
9882            .render(
9883                &streaming_protocol(true),
9884                &state,
9885                &RenderOptions::new("index.html", "/"),
9886                &mut with_boundaries,
9887            )
9888            .unwrap();
9889        handler
9890            .render(
9891                &streaming_protocol(false),
9892                &state,
9893                &RenderOptions::new("index.html", "/"),
9894                &mut without_boundaries,
9895            )
9896            .unwrap();
9897        assert_eq!(
9898            with_boundaries.get_content(),
9899            without_boundaries.get_content()
9900        );
9901    }
9902
9903    #[test]
9904    fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
9905        for source in [
9906            r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
9907            r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
9908        ] {
9909            let mut parser = HtmlParser::new();
9910            parser
9911                .parse("index.html", source)
9912                .expect("parse head fixture");
9913            let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
9914            let mut writer = TestWriter::new();
9915            WebUIHandler::new()
9916                .render(
9917                    &protocol,
9918                    &test_json!({ "theme": "light" }),
9919                    &RenderOptions::new("index.html", "/"),
9920                    &mut writer,
9921                )
9922                .unwrap();
9923
9924            assert_eq!(
9925                writer.get_content(),
9926                "<html><head><title>T</title></head><body>x</body></html>"
9927            );
9928        }
9929    }
9930
9931    #[test]
9932    fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
9933        let source =
9934            r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
9935        let mut parser = HtmlParser::new();
9936        parser.parse("index.html", source).expect("parse fixture");
9937        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
9938
9939        let mut ordinary = TestWriter::new();
9940        WebUIHandler::new()
9941            .render(
9942                &protocol,
9943                &test_json!({}),
9944                &RenderOptions::new("index.html", "/"),
9945                &mut ordinary,
9946            )
9947            .unwrap();
9948        assert_eq!(ordinary.get_content(), source);
9949
9950        let mut streaming = FlushTestWriter::default();
9951        WebUIHandler::new()
9952            .render_streaming(
9953                &protocol,
9954                &test_json!({}),
9955                &RenderOptions::new("index.html", "/"),
9956                &mut streaming,
9957            )
9958            .unwrap();
9959        assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
9960        assert!(streaming.output.contains("</HEAD><BODY>x"));
9961        let opening = streaming.output.find("<HEAD").expect("mixed-case head");
9962        let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
9963        let title = streaming.output.find("<title>").expect("title");
9964        assert!(opening < marker && marker < title);
9965        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
9966    }
9967
9968    /// Only `}}}webui:`-namespaced signals are compiler-owned, so an
9969    /// unprefixed `body_end` is ordinary authored content.
9970    #[test]
9971    fn unnamespaced_signal_is_ordinary_content() {
9972        let fragments = HashMap::from([(
9973            "index.html".to_string(),
9974            FragmentList {
9975                fragments: vec![
9976                    WebUIFragment::raw("<html><head>"),
9977                    structural_fragment("head_end"),
9978                    WebUIFragment::raw("</head><body>"),
9979                    WebUIFragment::signal("body_end".to_string(), false),
9980                    structural_fragment("body_end"),
9981                    WebUIFragment::raw("</body></html>"),
9982                ],
9983            },
9984        )]);
9985        let protocol = Protocol::new(WebUIProtocol::new(fragments));
9986        let mut writer = TestWriter::new();
9987        WebUIHandler::new()
9988            .render(
9989                &protocol,
9990                &test_json!({ "body_end": "content" }),
9991                &RenderOptions::new("index.html", "/"),
9992                &mut writer,
9993            )
9994            .expect("current protocol must render");
9995        assert!(writer.get_content().contains("content"));
9996    }
9997
9998    #[test]
9999    fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
10000        let source = concat!(
10001            "<html><head></head><body>",
10002            "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
10003            "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
10004            "</body></html>",
10005        );
10006        let state = test_json!({
10007            "head_start": "hs",
10008            "head_end": "he",
10009            "body_start": "bs",
10010            "body_end": "be",
10011            "boundary_start:0": "b0s",
10012            "boundary_end:0": "b0e",
10013            "streaming_root:forged": "root",
10014        });
10015        let mut parser = HtmlParser::new();
10016        parser.parse("index.html", source).expect("parse fixture");
10017        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
10018        let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
10019
10020        let mut ordinary = TestWriter::new();
10021        WebUIHandler::new()
10022            .render(
10023                &protocol,
10024                &state,
10025                &RenderOptions::new("index.html", "/"),
10026                &mut ordinary,
10027            )
10028            .unwrap();
10029        assert_eq!(ordinary.get_content(), expected);
10030
10031        let mut streaming = FlushTestWriter::default();
10032        WebUIHandler::new()
10033            .render_streaming(
10034                &protocol,
10035                &state,
10036                &RenderOptions::new("index.html", "/"),
10037                &mut streaming,
10038            )
10039            .unwrap();
10040        assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
10041        assert!(!streaming.output.contains("<!--wb:"));
10042        assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
10043        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
10044    }
10045
10046    #[test]
10047    fn streaming_rejects_structural_signals_after_terminal() {
10048        let fragments = HashMap::from([(
10049            "index.html".to_string(),
10050            FragmentList {
10051                fragments: vec![
10052                    WebUIFragment::raw("<html><head>"),
10053                    structural_fragment("head_start"),
10054                    structural_fragment("head_end"),
10055                    WebUIFragment::raw("</head><body>"),
10056                    structural_fragment("body_start"),
10057                    structural_fragment("body_end"),
10058                    WebUIFragment::raw("</body>"),
10059                    structural_fragment("boundary_start:0"),
10060                    WebUIFragment::raw("</html>"),
10061                ],
10062            },
10063        )]);
10064        let protocol = Protocol::new(WebUIProtocol::new(fragments));
10065        let mut streaming = FlushTestWriter::default();
10066        let result = WebUIHandler::new().render_streaming(
10067            &protocol,
10068            &test_json!({}),
10069            &RenderOptions::new("index.html", "/"),
10070            &mut streaming,
10071        );
10072
10073        match result {
10074            Err(HandlerError::StreamingBoundary(error)) => {
10075                assert_eq!(error.signal, "boundary_start:0");
10076                assert!(error.reason.contains("after the body_end terminal record"));
10077            }
10078            other => panic!("expected post-terminal rejection, got {other:?}"),
10079        }
10080        assert!(streaming.output.is_empty());
10081        assert!(!streaming.output.contains("<!--wb:0-->"));
10082
10083        let mut ordinary = TestWriter::new();
10084        WebUIHandler::new()
10085            .render(
10086                &protocol,
10087                &test_json!({}),
10088                &RenderOptions::new("index.html", "/"),
10089                &mut ordinary,
10090            )
10091            .unwrap();
10092        assert_eq!(
10093            ordinary.get_content(),
10094            "<html><head></head><body></body></html>"
10095        );
10096    }
10097
10098    /// Build a streaming protocol with one boundary per `hosts` entry. Each
10099    /// boundary wraps a component host whose opening tag is split so the
10100    /// compiler-owned `streaming_root:<tag>` signal (optionally emitted) lands
10101    /// inside the tag, exactly as `HtmlParser` produces. Components `comp-a` and
10102    /// `comp-b` carry disjoint templates and disjoint hydration keys.
10103    fn disjoint_streaming_protocol_ext(hosts: &[&str], emit_root_signal: bool) -> Protocol {
10104        let mut fragments = HashMap::new();
10105        let mut entry = vec![
10106            WebUIFragment::raw("<!DOCTYPE html><html><head>"),
10107            structural_fragment("head_start"),
10108            structural_fragment("head_end"),
10109            WebUIFragment::raw("</head><body>"),
10110            structural_fragment("body_start"),
10111        ];
10112        for (sequence, host) in hosts.iter().enumerate() {
10113            entry.push(structural_fragment(format!("boundary_start:{sequence}")));
10114            entry.push(WebUIFragment::raw(format!("<{host}")));
10115            if emit_root_signal {
10116                entry.push(structural_fragment(format!("streaming_root:{host}")));
10117            }
10118            entry.push(WebUIFragment::raw(">"));
10119            entry.push(WebUIFragment::component(*host));
10120            entry.push(WebUIFragment::raw(format!("</{host}>")));
10121            entry.push(structural_fragment(format!("boundary_end:{sequence}")));
10122        }
10123        entry.push(structural_fragment("body_end"));
10124        entry.push(WebUIFragment::raw("</body></html>"));
10125        fragments.insert("index.html".to_string(), FragmentList { fragments: entry });
10126        fragments.insert(
10127            "comp-a".to_string(),
10128            FragmentList {
10129                fragments: vec![WebUIFragment::raw("<b>A</b>")],
10130            },
10131        );
10132        fragments.insert(
10133            "comp-b".to_string(),
10134            FragmentList {
10135                fragments: vec![WebUIFragment::raw("<b>B</b>")],
10136            },
10137        );
10138
10139        let mut document = WebUIProtocol::new(fragments);
10140        document.initial_state_strategy = InitialStateStrategy::Components as i32;
10141        document.streaming_boundaries.insert(
10142            "index.html".to_string(),
10143            webui_protocol::StreamingBoundaryList {
10144                names: (0..hosts.len())
10145                    .map(|index| format!("boundary-{index}"))
10146                    .collect(),
10147            },
10148        );
10149        document.components.insert(
10150            "comp-a".to_string(),
10151            webui_protocol::ComponentData {
10152                template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
10153                hydration_mode: StateProjectionMode::Keys as i32,
10154                hydration_keys: vec!["a_count".to_string()],
10155                ..Default::default()
10156            },
10157        );
10158        document.components.insert(
10159            "comp-b".to_string(),
10160            webui_protocol::ComponentData {
10161                template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
10162                hydration_mode: StateProjectionMode::Keys as i32,
10163                hydration_keys: vec!["b_count".to_string()],
10164                ..Default::default()
10165            },
10166        );
10167        Protocol::new(document)
10168    }
10169
10170    fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
10171        disjoint_streaming_protocol_ext(hosts, true)
10172    }
10173
10174    fn streaming_plan_validation_protocol(signals: &[&str], names: &[&str]) -> Protocol {
10175        let mut entry = vec![
10176            WebUIFragment::raw("<html><head>"),
10177            structural_fragment("head_start"),
10178            structural_fragment("head_end"),
10179            WebUIFragment::raw("</head><body>"),
10180            structural_fragment("body_start"),
10181        ];
10182        entry.extend(signals.iter().map(structural_fragment));
10183        entry.extend([
10184            structural_fragment("body_end"),
10185            WebUIFragment::raw("</body></html>"),
10186        ]);
10187
10188        let mut document = WebUIProtocol::new(HashMap::from([(
10189            "index.html".to_string(),
10190            FragmentList { fragments: entry },
10191        )]));
10192        document.streaming_boundaries.insert(
10193            "index.html".to_string(),
10194            webui_protocol::StreamingBoundaryList {
10195                names: names.iter().map(|name| (*name).to_string()).collect(),
10196            },
10197        );
10198        Protocol::new(document)
10199    }
10200
10201    #[test]
10202    fn streaming_response_rejects_cached_malformed_boundary_plan_before_writing() {
10203        let protocol =
10204            streaming_plan_validation_protocol(&["boundary_start:0", "boundary_end:1"], &["one"]);
10205        let handler = WebUIHandler::new();
10206        let mut writer = FlushTestWriter::default();
10207        let options = RenderOptions::new("index.html", "/");
10208
10209        let error = match handler.stream_response(&protocol, &options, &mut writer) {
10210            Ok(_) => panic!("malformed boundary plan unexpectedly opened a response"),
10211            Err(error) => error,
10212        };
10213
10214        assert!(
10215            matches!(error, HandlerError::StreamingBoundary(_)),
10216            "error: {error}"
10217        );
10218        assert!(writer.output.is_empty());
10219        assert!(writer.flushes.is_empty());
10220    }
10221
10222    #[test]
10223    fn streaming_response_rejects_boundary_name_count_mismatch_before_writing() {
10224        let protocol = streaming_plan_validation_protocol(
10225            &["boundary_start:0", "boundary_end:0"],
10226            &["one", "two"],
10227        );
10228        let handler = WebUIHandler::new();
10229        let mut writer = FlushTestWriter::default();
10230        let options = RenderOptions::new("index.html", "/");
10231
10232        let error = match handler.stream_response(&protocol, &options, &mut writer) {
10233            Ok(_) => panic!("mismatched boundary names unexpectedly opened a response"),
10234            Err(error) => error,
10235        };
10236
10237        assert!(
10238            matches!(error, HandlerError::Invariant(_)),
10239            "error: {error}"
10240        );
10241        assert!(writer.output.is_empty());
10242        assert!(writer.flushes.is_empty());
10243    }
10244
10245    #[test]
10246    fn streaming_response_interleaves_projected_updates_with_later_boundaries() {
10247        let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b"]);
10248        let handler = WebUIHandler::with_plugin(|| {
10249            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10250        });
10251        let mut writer = FlushTestWriter::default();
10252        let options = RenderOptions::new("index.html", "/");
10253        let mut response = handler
10254            .stream_response(&protocol, &options, &mut writer)
10255            .unwrap();
10256        let first = response.boundary("boundary-0").unwrap();
10257        let second = response.boundary("boundary-1").unwrap();
10258
10259        response.write_shell(&test_json!({})).unwrap();
10260        response
10261            .write_boundary(
10262                first,
10263                &test_json!({ "a_count": 1, "serverOnly": "secret" }),
10264                BoundaryMode::Updatable,
10265            )
10266            .unwrap();
10267        response
10268            .update(first, &test_json!({ "a_count": 7, "serverOnly": "secret" }))
10269            .unwrap();
10270        response
10271            .write_boundary(
10272                second,
10273                &test_json!({ "b_count": 2, "serverOnly": "secret" }),
10274                BoundaryMode::Final,
10275            )
10276            .unwrap();
10277        response.finish(&test_json!({})).unwrap();
10278
10279        assert_eq!(writer.flushes.len(), 5);
10280        assert!(writer.output.contains(r#"[1,0,1,0,{"#));
10281        assert!(writer.output.contains(r#""state":{"a_count":1}"#));
10282        assert!(writer.output.contains(r#"[1,1,2,0,{"a_count":7}]"#));
10283        assert!(writer.output.contains(r#"[1,2,0,1,{"#));
10284        assert!(writer.output.contains(r#""state":{"b_count":2}"#));
10285        assert!(writer.output.contains("[1,3,3,0,{}]"));
10286        assert_eq!(writer.output.matches("serverOnly").count(), 0);
10287
10288        let update_start = writer.output.find("[1,1,2,0,").unwrap();
10289        let update_end = writer.output[update_start..]
10290            .find("</script>")
10291            .map(|offset| update_start + offset)
10292            .unwrap();
10293        let update = &writer.output[update_start..update_end];
10294        assert!(!update.contains("inventory"));
10295        assert!(!update.contains("templates"));
10296    }
10297
10298    #[test]
10299    fn owned_streaming_session_matches_borrowed_response_bytes() {
10300        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a", "comp-b"]));
10301        let handler = Arc::new(WebUIHandler::with_plugin(|| {
10302            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10303        }));
10304
10305        let mut writer = FlushTestWriter::default();
10306        let options = RenderOptions::new("index.html", "/");
10307        let mut response = handler
10308            .stream_response(&protocol, &options, &mut writer)
10309            .unwrap();
10310        let first = response.boundary("boundary-0").unwrap();
10311        let second = response.boundary("boundary-1").unwrap();
10312        response.write_shell(&test_json!({})).unwrap();
10313        response
10314            .write_boundary(
10315                first,
10316                &test_json!({ "a_count": 1 }),
10317                BoundaryMode::Updatable,
10318            )
10319            .unwrap();
10320        response
10321            .update(first, &test_json!({ "a_count": 7 }))
10322            .unwrap();
10323        response
10324            .write_boundary(second, &test_json!({ "b_count": 2 }), BoundaryMode::Final)
10325            .unwrap();
10326        response.finish(&test_json!({})).unwrap();
10327
10328        let mut session = StreamingSession::new(
10329            Arc::clone(&handler),
10330            Arc::clone(&protocol),
10331            SessionOptions::new("index.html", "/"),
10332        )
10333        .unwrap();
10334        let session_first = session.boundary("boundary-0").unwrap();
10335        let session_second = session.boundary("boundary-1").unwrap();
10336        assert_eq!(session_first, first);
10337        assert_eq!(session_second, second);
10338        assert_eq!(session.boundary_count(), 2);
10339
10340        let chunks: Vec<Vec<u8>> = vec![
10341            session.write_shell(&test_json!({})).unwrap(),
10342            session
10343                .write_boundary(
10344                    session_first,
10345                    &test_json!({ "a_count": 1 }),
10346                    BoundaryMode::Updatable,
10347                )
10348                .unwrap(),
10349            session
10350                .update(session_first, &test_json!({ "a_count": 7 }))
10351                .unwrap(),
10352            session
10353                .write_boundary(
10354                    session_second,
10355                    &test_json!({ "b_count": 2 }),
10356                    BoundaryMode::Final,
10357                )
10358                .unwrap(),
10359            session.finish(&test_json!({})).unwrap(),
10360        ];
10361
10362        // One chunk per host call, matching the borrowed path's flush count.
10363        assert_eq!(chunks.len(), writer.flushes.len());
10364        assert!(session.is_finished());
10365        let joined = String::from_utf8(chunks.concat()).unwrap();
10366        assert_eq!(joined, writer.output);
10367    }
10368
10369    #[test]
10370    fn owned_streaming_session_rejects_use_after_finish() {
10371        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"]));
10372        let handler = Arc::new(WebUIHandler::new());
10373        let mut session =
10374            StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
10375                .unwrap();
10376        let boundary = session.boundary("boundary-0").unwrap();
10377        session.write_shell(&test_json!({})).unwrap();
10378        session
10379            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
10380            .unwrap();
10381        session.finish(&test_json!({})).unwrap();
10382
10383        let error = session.write_shell(&test_json!({})).unwrap_err();
10384        assert!(error.to_string().contains("already finished"));
10385        assert!(session.finish(&test_json!({})).is_err());
10386    }
10387
10388    #[test]
10389    fn owned_streaming_session_surfaces_unknown_boundary_names() {
10390        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"]));
10391        let handler = Arc::new(WebUIHandler::new());
10392        let session =
10393            StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
10394                .unwrap();
10395        let error = session.boundary("boundary-O").unwrap_err().to_string();
10396        assert!(error.contains("boundary-0"));
10397    }
10398
10399    #[test]
10400    fn owned_streaming_session_stays_usable_after_a_rejected_call() {
10401        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"]));
10402        let handler = Arc::new(WebUIHandler::new());
10403        let mut session =
10404            StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
10405                .unwrap();
10406        let boundary = session.boundary("boundary-0").unwrap();
10407        session.write_shell(&test_json!({})).unwrap();
10408        // Rejected before any byte is written, so the session is not poisoned.
10409        assert!(session.update(boundary, &test_json!({ "a": 1 })).is_err());
10410        assert!(!session.is_finished());
10411        session
10412            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
10413            .unwrap();
10414        session.finish(&test_json!({})).unwrap();
10415    }
10416
10417    #[test]
10418    fn owned_streaming_session_recovers_from_a_rejected_finish() {
10419        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a", "comp-b"]));
10420        let handler = Arc::new(WebUIHandler::new());
10421        let mut session =
10422            StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
10423                .unwrap();
10424        let first = session.boundary("boundary-0").unwrap();
10425        let second = session.boundary("boundary-1").unwrap();
10426        session.write_shell(&test_json!({})).unwrap();
10427        session
10428            .write_boundary(first, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
10429            .unwrap();
10430
10431        // Rejected before any byte is written, so the response must survive.
10432        let error = session.finish(&test_json!({})).unwrap_err();
10433        assert!(
10434            error
10435                .to_string()
10436                .contains("every boundary must be committed"),
10437            "unexpected error: {error}"
10438        );
10439        assert!(!session.is_finished());
10440
10441        session
10442            .write_boundary(second, &test_json!({ "b_count": 1 }), BoundaryMode::Final)
10443            .unwrap();
10444        let tail = session.finish(&test_json!({})).unwrap();
10445        assert!(!tail.is_empty());
10446        assert!(session.is_finished());
10447    }
10448
10449    #[test]
10450    fn owned_streaming_session_rejects_finish_before_the_shell() {
10451        let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"]));
10452        let handler = Arc::new(WebUIHandler::new());
10453        let mut session =
10454            StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
10455                .unwrap();
10456        let boundary = session.boundary("boundary-0").unwrap();
10457
10458        let error = session.finish(&test_json!({})).unwrap_err();
10459        assert!(
10460            error.to_string().contains("write_shell must be called"),
10461            "unexpected error: {error}"
10462        );
10463        assert!(!session.is_finished());
10464
10465        session.write_shell(&test_json!({})).unwrap();
10466        session
10467            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
10468            .unwrap();
10469        session.finish(&test_json!({})).unwrap();
10470        assert!(session.is_finished());
10471    }
10472
10473    #[test]
10474    fn streaming_response_is_poisoned_after_partial_boundary_flush_failure() {
10475        let protocol = disjoint_streaming_protocol(&["comp-a"]);
10476        let handler = WebUIHandler::new();
10477        let mut writer = FlushTestWriter {
10478            fail_flush_at: Some(1),
10479            ..FlushTestWriter::default()
10480        };
10481        let options = RenderOptions::new("index.html", "/");
10482        let mut response = handler
10483            .stream_response(&protocol, &options, &mut writer)
10484            .unwrap();
10485        let boundary = response.boundary("boundary-0").unwrap();
10486        response.write_shell(&test_json!({})).unwrap();
10487
10488        let first_error = response
10489            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
10490            .unwrap_err();
10491        assert!(matches!(first_error, HandlerError::ClientDisconnected));
10492
10493        let retry_error = response
10494            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
10495            .unwrap_err();
10496        assert!(
10497            retry_error
10498                .to_string()
10499                .contains("unusable after a previous render or transport failure"),
10500            "error: {retry_error}"
10501        );
10502
10503        drop(response);
10504        assert_eq!(writer.output.matches("<!--wb:0-->").count(), 1);
10505        assert_eq!(writer.flushes.len(), 1);
10506        assert!(!writer.ended);
10507    }
10508
10509    #[test]
10510    fn streaming_response_rejects_updates_to_final_boundaries() {
10511        let protocol = disjoint_streaming_protocol(&["comp-a"]);
10512        let handler = WebUIHandler::new();
10513        let mut writer = FlushTestWriter::default();
10514        let options = RenderOptions::new("index.html", "/");
10515        let mut response = handler
10516            .stream_response(&protocol, &options, &mut writer)
10517            .unwrap();
10518        let boundary = response.boundary("boundary-0").unwrap();
10519        response.write_shell(&test_json!({})).unwrap();
10520        response
10521            .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
10522            .unwrap();
10523
10524        let error = response
10525            .update(boundary, &test_json!({ "a_count": 2 }))
10526            .unwrap_err();
10527        assert!(
10528            error.to_string().contains("committed as final"),
10529            "error: {error}"
10530        );
10531    }
10532
10533    #[test]
10534    fn streaming_response_rejects_non_object_updates_before_writing() {
10535        let protocol = disjoint_streaming_protocol(&["comp-a"]);
10536        let handler = WebUIHandler::new();
10537        let mut writer = FlushTestWriter::default();
10538        let options = RenderOptions::new("index.html", "/");
10539        let mut response = handler
10540            .stream_response(&protocol, &options, &mut writer)
10541            .unwrap();
10542        let boundary = response.boundary("boundary-0").unwrap();
10543        response.write_shell(&test_json!({})).unwrap();
10544        response
10545            .write_boundary(
10546                boundary,
10547                &test_json!({ "a_count": 1 }),
10548                BoundaryMode::Updatable,
10549            )
10550            .unwrap();
10551        let error = response
10552            .update(boundary, &test_json!("invalid"))
10553            .unwrap_err();
10554        assert!(error.to_string().contains("JSON object"), "error: {error}");
10555
10556        response
10557            .update(boundary, &test_json!({ "a_count": 2 }))
10558            .unwrap();
10559        response.finish(&test_json!({})).unwrap();
10560        assert_eq!(writer.flushes.len(), 4);
10561        assert_eq!(writer.output.matches("data-webui-boundary").count(), 3);
10562        assert!(writer.output.contains(r#"[1,1,2,0,{"a_count":2}]"#));
10563    }
10564
10565    #[test]
10566    fn streaming_response_unknown_boundary_suggests_valid_name() {
10567        let protocol = disjoint_streaming_protocol(&["comp-a"]);
10568        let handler = WebUIHandler::new();
10569        let mut writer = FlushTestWriter::default();
10570        let options = RenderOptions::new("index.html", "/");
10571        let response = handler
10572            .stream_response(&protocol, &options, &mut writer)
10573            .unwrap();
10574
10575        let error = response.boundary("boundry-0").unwrap_err();
10576        assert!(
10577            error.to_string().contains("did you mean `boundary-0`?"),
10578            "error: {error}"
10579        );
10580    }
10581
10582    fn streaming_root_validation_protocol(
10583        mut host_fragments: Vec<WebUIFragment>,
10584        tail: Vec<WebUIFragment>,
10585    ) -> Protocol {
10586        let mut entry = vec![
10587            WebUIFragment::raw("<html><head>"),
10588            structural_fragment("head_start"),
10589            structural_fragment("head_end"),
10590            WebUIFragment::raw("</head><body>"),
10591            structural_fragment("body_start"),
10592            structural_fragment("boundary_start:0"),
10593        ];
10594        entry.append(&mut host_fragments);
10595        entry.extend(tail);
10596        let fragments = HashMap::from([
10597            ("index.html".to_string(), FragmentList { fragments: entry }),
10598            (
10599                "comp-a".to_string(),
10600                FragmentList {
10601                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
10602                },
10603            ),
10604            (
10605                "comp-b".to_string(),
10606                FragmentList {
10607                    fragments: vec![WebUIFragment::raw("<b>B</b>")],
10608                },
10609            ),
10610        ]);
10611        Protocol::new(WebUIProtocol::new(fragments))
10612    }
10613
10614    fn completed_streaming_tail() -> Vec<WebUIFragment> {
10615        vec![
10616            WebUIFragment::raw("</comp-a>"),
10617            structural_fragment("boundary_end:0"),
10618            structural_fragment("body_end"),
10619            WebUIFragment::raw("</body></html>"),
10620        ]
10621    }
10622
10623    fn assert_streaming_root_error(
10624        protocol: &Protocol,
10625        expected_signal: &str,
10626        expected_reason: &str,
10627    ) {
10628        let mut writer = FlushTestWriter::default();
10629        let result = WebUIHandler::new().render_streaming(
10630            protocol,
10631            &test_json!({}),
10632            &RenderOptions::new("index.html", "/"),
10633            &mut writer,
10634        );
10635        match result {
10636            Err(HandlerError::StreamingBoundary(error)) => {
10637                assert_eq!(error.signal, expected_signal);
10638                assert!(
10639                    error.reason.contains(expected_reason),
10640                    "reason: {}",
10641                    error.reason
10642                );
10643            }
10644            other => panic!("expected streaming-root rejection, got {other:?}"),
10645        }
10646    }
10647
10648    #[test]
10649    fn streaming_root_signal_injects_data_ws_inside_boundary() {
10650        let protocol = disjoint_streaming_protocol(&["comp-a"]);
10651        let handler = WebUIHandler::with_plugin(|| {
10652            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10653        });
10654        let mut writer = FlushTestWriter::default();
10655        handler
10656            .render_streaming(
10657                &protocol,
10658                &test_json!({ "a_count": 1 }),
10659                &RenderOptions::new("index.html", "/"),
10660                &mut writer,
10661            )
10662            .unwrap();
10663        // The parser-owned signal is consumed to inject exactly ` data-ws`
10664        // inside the host's opening tag, before the custom element upgrades.
10665        assert!(
10666            writer.output.contains("<comp-a data-ws>"),
10667            "streamed host must carry data-ws: {}",
10668            writer.output
10669        );
10670    }
10671
10672    #[test]
10673    fn streaming_root_signal_preserves_ordinary_output_bytes() {
10674        // Ordinary rendering ignores `streaming_root` byte-for-byte: identical
10675        // output with and without the signal, and never a `data-ws` attribute.
10676        let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true);
10677        let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false);
10678        let state = test_json!({ "a_count": 1, "b_count": 2 });
10679        let plugin = || {
10680            WebUIHandler::with_plugin(
10681                || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
10682            )
10683        };
10684        let mut with_writer = TestWriter::new();
10685        let mut without_writer = TestWriter::new();
10686        plugin()
10687            .render(
10688                &with_signal,
10689                &state,
10690                &RenderOptions::new("index.html", "/"),
10691                &mut with_writer,
10692            )
10693            .unwrap();
10694        plugin()
10695            .render(
10696                &without_signal,
10697                &state,
10698                &RenderOptions::new("index.html", "/"),
10699                &mut without_writer,
10700            )
10701            .unwrap();
10702        // The rendered DOM (everything up to the inert data block) is
10703        // deterministic and is where a leaked `data-ws` would appear. The
10704        // ordinary template map is HashSet-ordered, so compare the DOM prefix
10705        // for byte identity rather than the whole document.
10706        let dom_prefix = |content: &str| -> String {
10707            content
10708                .split_once(r#"<script type="application/json" id="webui-data""#)
10709                .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
10710        };
10711        assert_eq!(
10712            dom_prefix(&with_writer.get_content()),
10713            dom_prefix(&without_writer.get_content())
10714        );
10715        assert!(!with_writer.get_content().contains("data-ws"));
10716        assert!(with_writer.get_content().contains("<comp-a>"));
10717    }
10718
10719    #[test]
10720    fn streaming_component_without_root_signal_is_rejected() {
10721        let protocol = streaming_root_validation_protocol(
10722            vec![
10723                WebUIFragment::raw("<comp-a>"),
10724                WebUIFragment::component("comp-a"),
10725            ],
10726            completed_streaming_tail(),
10727        );
10728
10729        assert_streaming_root_error(
10730            &protocol,
10731            "streaming_root:comp-a",
10732            "has no matching compiler-owned root signal",
10733        );
10734    }
10735
10736    #[test]
10737    fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
10738        let duplicate = streaming_root_validation_protocol(
10739            vec![
10740                WebUIFragment::raw("<comp-a"),
10741                structural_fragment("streaming_root:comp-a"),
10742                structural_fragment("streaming_root:comp-a"),
10743                WebUIFragment::raw(">"),
10744                WebUIFragment::component("comp-a"),
10745            ],
10746            completed_streaming_tail(),
10747        );
10748        assert_streaming_root_error(&duplicate, "streaming_root:comp-a", "is misplaced");
10749
10750        let after_tag_close = streaming_root_validation_protocol(
10751            vec![
10752                WebUIFragment::raw("<comp-a>"),
10753                structural_fragment("streaming_root:comp-a"),
10754                WebUIFragment::component("comp-a"),
10755            ],
10756            completed_streaming_tail(),
10757        );
10758        assert_streaming_root_error(&after_tag_close, "streaming_root:comp-a", "is misplaced");
10759
10760        let detached_from_opening = streaming_root_validation_protocol(
10761            vec![
10762                WebUIFragment::raw("not an opening tag"),
10763                structural_fragment("streaming_root:comp-a"),
10764                WebUIFragment::raw(">"),
10765                WebUIFragment::component("comp-a"),
10766            ],
10767            completed_streaming_tail(),
10768        );
10769        assert_streaming_root_error(
10770            &detached_from_opening,
10771            "streaming_root:comp-a",
10772            "unclosed component opening-tag close",
10773        );
10774    }
10775
10776    #[test]
10777    fn mismatched_streaming_root_signal_is_rejected() {
10778        let protocol = streaming_root_validation_protocol(
10779            vec![
10780                WebUIFragment::raw("<comp-a"),
10781                structural_fragment("streaming_root:comp-b"),
10782                WebUIFragment::raw(">"),
10783                WebUIFragment::component("comp-a"),
10784            ],
10785            completed_streaming_tail(),
10786        );
10787
10788        assert_streaming_root_error(
10789            &protocol,
10790            "streaming_root:comp-b",
10791            "opening host renders component <comp-a>",
10792        );
10793    }
10794
10795    #[test]
10796    fn pending_streaming_root_is_rejected_at_structural_ends() {
10797        let protocol = streaming_root_validation_protocol(
10798            vec![
10799                WebUIFragment::raw("<comp-a"),
10800                structural_fragment("streaming_root:comp-a"),
10801                WebUIFragment::raw(">"),
10802            ],
10803            vec![structural_fragment("boundary_end:0")],
10804        );
10805        assert_streaming_root_error(&protocol, "streaming_root:comp-a", "is misplaced");
10806    }
10807
10808    #[test]
10809    fn streaming_root_outside_boundary_is_rejected() {
10810        let fragments = HashMap::from([
10811            (
10812                "index.html".to_string(),
10813                FragmentList {
10814                    fragments: vec![
10815                        WebUIFragment::raw("<html><head>"),
10816                        structural_fragment("head_start"),
10817                        structural_fragment("head_end"),
10818                        WebUIFragment::raw("</head><body>"),
10819                        structural_fragment("body_start"),
10820                        WebUIFragment::raw("<comp-a"),
10821                        structural_fragment("streaming_root:comp-a"),
10822                        WebUIFragment::raw(">"),
10823                        WebUIFragment::component("comp-a"),
10824                        WebUIFragment::raw("</comp-a>"),
10825                        structural_fragment("body_end"),
10826                        WebUIFragment::raw("</body></html>"),
10827                    ],
10828                },
10829            ),
10830            (
10831                "comp-a".to_string(),
10832                FragmentList {
10833                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
10834                },
10835            ),
10836        ]);
10837        let protocol = Protocol::new(WebUIProtocol::new(fragments));
10838        let mut writer = FlushTestWriter::default();
10839        let result = WebUIHandler::new().render_streaming(
10840            &protocol,
10841            &test_json!({}),
10842            &RenderOptions::new("index.html", "/"),
10843            &mut writer,
10844        );
10845        match result {
10846            Err(HandlerError::StreamingBoundary(err)) => {
10847                assert_eq!(err.signal, "streaming_root:comp-a");
10848                assert!(
10849                    err.reason.contains("outside any <boundary>"),
10850                    "reason: {}",
10851                    err.reason
10852                );
10853            }
10854            other => panic!("expected outside-boundary rejection, got {other:?}"),
10855        }
10856    }
10857
10858    fn parser_route_protocol(with_boundary: bool) -> Protocol {
10859        let mut parser = HtmlParser::with_options(DomStrategy::Light);
10860        parser
10861            .component_registry_mut()
10862            .register_component(ComponentRegistration::new(
10863                "route-page",
10864                "<p>route content</p>",
10865                None,
10866                true,
10867            ))
10868            .expect("register route component");
10869        let route = r#"<route path="/" component="route-page" exact />"#;
10870        let html = if with_boundary {
10871            format!(
10872                "<html><head></head><body>\
10873                 <boundary name=\"route\">{route}</boundary>\
10874                 </body></html>"
10875            )
10876        } else {
10877            format!("<html><head></head><body>{route}</body></html>")
10878        };
10879        parser
10880            .parse("index.html", &html)
10881            .expect("parse route streaming fixture");
10882        let mut document = WebUIProtocol::new(parser.into_fragment_records());
10883        document.components.insert(
10884            "route-page".to_string(),
10885            webui_protocol::ComponentData::default(),
10886        );
10887        Protocol::new(document)
10888    }
10889
10890    #[test]
10891    fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
10892        let protocol = parser_route_protocol(true);
10893        let mut writer = FlushTestWriter::default();
10894        WebUIHandler::new()
10895            .render_streaming(
10896                &protocol,
10897                &test_json!({}),
10898                &RenderOptions::new("index.html", "/"),
10899                &mut writer,
10900            )
10901            .unwrap();
10902
10903        assert!(
10904            writer
10905                .output
10906                .contains("<route-page data-ws><p>route content</p></route-page>"),
10907            "matched route host must be deferred before upgrade: {}",
10908            writer.output
10909        );
10910    }
10911
10912    #[test]
10913    fn streaming_parser_route_host_outside_boundary_is_rejected() {
10914        let protocol = parser_route_protocol(false);
10915        let mut writer = FlushTestWriter::default();
10916        let result = WebUIHandler::new().render_streaming(
10917            &protocol,
10918            &test_json!({}),
10919            &RenderOptions::new("index.html", "/"),
10920            &mut writer,
10921        );
10922
10923        match result {
10924            Err(HandlerError::StreamingBoundary(error)) => {
10925                assert_eq!(error.signal, "streaming_root:route-page");
10926                assert!(error.reason.contains("outside any <boundary>"));
10927            }
10928            other => panic!("expected route boundary rejection, got {other:?}"),
10929        }
10930        assert!(
10931            !writer.output.contains("<route-page>"),
10932            "an unmarked custom-element opening must never be completed"
10933        );
10934    }
10935
10936    #[test]
10937    fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
10938        let entry = vec![
10939            WebUIFragment::raw("<html><head>"),
10940            structural_fragment("head_start"),
10941            structural_fragment("head_end"),
10942            WebUIFragment::raw("</head><body>"),
10943            structural_fragment("body_start"),
10944            structural_fragment("boundary_start:0"),
10945            WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
10946                path: "/account".into(),
10947                fragment_id: "account-shell".into(),
10948                exact: false,
10949                ..Default::default()
10950            }),
10951            structural_fragment("boundary_end:0"),
10952            structural_fragment("body_end"),
10953            WebUIFragment::raw("</body></html>"),
10954        ];
10955        let fragments = HashMap::from([
10956            ("index.html".to_string(), FragmentList { fragments: entry }),
10957            (
10958                "account-shell".to_string(),
10959                FragmentList {
10960                    fragments: vec![WebUIFragment::route_from(
10961                        webui_protocol::WebUiFragmentRoute {
10962                            path: "./details".into(),
10963                            fragment_id: "details-page".into(),
10964                            exact: true,
10965                            pending_component: "details-loading".into(),
10966                            error_component: "details-error".into(),
10967                            ..Default::default()
10968                        },
10969                    )],
10970                },
10971            ),
10972            (
10973                "details-page".to_string(),
10974                FragmentList {
10975                    fragments: vec![WebUIFragment::raw("<p>details</p>")],
10976                },
10977            ),
10978            (
10979                "details-loading".to_string(),
10980                FragmentList {
10981                    fragments: vec![WebUIFragment::raw("<p>loading</p>")],
10982                },
10983            ),
10984            (
10985                "details-error".to_string(),
10986                FragmentList {
10987                    fragments: vec![WebUIFragment::raw("<p>error</p>")],
10988                },
10989            ),
10990        ]);
10991        let mut document = WebUIProtocol::new(fragments);
10992        document.initial_state_strategy = InitialStateStrategy::Components as i32;
10993        for name in [
10994            "account-shell",
10995            "details-page",
10996            "details-loading",
10997            "details-error",
10998        ] {
10999            document.components.insert(
11000                name.to_string(),
11001                webui_protocol::ComponentData {
11002                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
11003                    ..Default::default()
11004                },
11005            );
11006        }
11007        let protocol = Protocol::new(document);
11008        let handler = WebUIHandler::with_plugin(|| {
11009            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11010        });
11011        let mut writer = FlushTestWriter::default();
11012
11013        handler
11014            .render_streaming(
11015                &protocol,
11016                &test_json!({}),
11017                &RenderOptions::new("index.html", "/account/details"),
11018                &mut writer,
11019            )
11020            .unwrap();
11021
11022        let first = &writer.output[..writer.flushes[0]];
11023        assert!(
11024            first.contains(r#""details-loading":"#),
11025            "relative-route pending metadata missing: {first}"
11026        );
11027        assert!(
11028            first.contains(r#""details-error":"#),
11029            "relative-route error metadata missing: {first}"
11030        );
11031    }
11032
11033    #[test]
11034    fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
11035        let entry = vec![
11036            WebUIFragment::raw("<html><head>"),
11037            structural_fragment("head_start"),
11038            structural_fragment("head_end"),
11039            WebUIFragment::raw("</head><body>"),
11040            structural_fragment("body_start"),
11041            structural_fragment("boundary_start:0"),
11042            WebUIFragment::raw("<static-shell"),
11043            structural_fragment("streaming_root:static-shell"),
11044            WebUIFragment::raw(">"),
11045            WebUIFragment::component("static-shell"),
11046            WebUIFragment::raw("</static-shell>"),
11047            WebUIFragment::raw("<route-shell"),
11048            structural_fragment("streaming_root:route-shell"),
11049            WebUIFragment::raw(">"),
11050            WebUIFragment::component("route-shell"),
11051            WebUIFragment::raw("</route-shell>"),
11052            structural_fragment("boundary_end:0"),
11053            structural_fragment("body_end"),
11054            WebUIFragment::raw("</body></html>"),
11055        ];
11056        let fragments = HashMap::from([
11057            ("index.html".to_string(), FragmentList { fragments: entry }),
11058            (
11059                "static-shell".to_string(),
11060                FragmentList {
11061                    fragments: vec![WebUIFragment::if_cond(
11062                        ConditionExpr::identifier("show_hidden"),
11063                        "static-hidden-if",
11064                    )],
11065                },
11066            ),
11067            (
11068                "static-hidden-if".to_string(),
11069                FragmentList {
11070                    fragments: vec![WebUIFragment::component("static-hidden")],
11071                },
11072            ),
11073            (
11074                "static-hidden".to_string(),
11075                FragmentList {
11076                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
11077                },
11078            ),
11079            (
11080                "route-shell".to_string(),
11081                FragmentList {
11082                    fragments: vec![WebUIFragment::route_from(
11083                        webui_protocol::WebUiFragmentRoute {
11084                            path: "/account".into(),
11085                            fragment_id: "route-page".into(),
11086                            exact: true,
11087                            ..Default::default()
11088                        },
11089                    )],
11090                },
11091            ),
11092            (
11093                "route-page".to_string(),
11094                FragmentList {
11095                    fragments: vec![WebUIFragment::raw("<p>route</p>")],
11096                },
11097            ),
11098        ]);
11099        let mut document = WebUIProtocol::new(fragments);
11100        document.set_css_strategy(webui_protocol::CssStrategy::Link);
11101        document.initial_state_strategy = InitialStateStrategy::Components as i32;
11102        for (name, keys) in [
11103            ("static-shell", vec!["show_hidden", "static_count"]),
11104            ("static-hidden", vec!["hidden_count"]),
11105            ("route-shell", vec!["route_shell_count"]),
11106            ("route-page", vec!["route_count"]),
11107        ] {
11108            document.components.insert(
11109                name.to_string(),
11110                webui_protocol::ComponentData {
11111                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
11112                    hydration_mode: StateProjectionMode::Keys as i32,
11113                    hydration_keys: keys.into_iter().map(str::to_string).collect(),
11114                    css_href: format!("/{name}.css"),
11115                    ..Default::default()
11116                },
11117            );
11118        }
11119        let protocol = Protocol::new(document);
11120        assert_eq!(
11121            protocol
11122                .component_reachability()
11123                .is_route_dependent(protocol.component_index()["route-shell"]),
11124            Some(true),
11125            "fixture must exercise request-aware checkpoint reachability"
11126        );
11127        let handler = WebUIHandler::with_plugin(|| {
11128            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11129        });
11130        let mut writer = FlushTestWriter::default();
11131
11132        handler
11133            .render_streaming(
11134                &protocol,
11135                &test_json!({
11136                    "show_hidden": false,
11137                    "static_count": 1,
11138                    "hidden_count": 2,
11139                    "route_shell_count": 3,
11140                    "route_count": 4,
11141                    "unrelated": "private",
11142                }),
11143                &RenderOptions::new("index.html", "/account"),
11144                &mut writer,
11145            )
11146            .unwrap();
11147
11148        let checkpoint = &writer.output[..writer.flushes[0]];
11149        for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
11150            assert!(
11151                checkpoint.contains(&format!(r#""{template}":"#)),
11152                "{template} metadata missing: {checkpoint}"
11153            );
11154        }
11155        for state in [
11156            r#""show_hidden":false"#,
11157            r#""static_count":1"#,
11158            r#""hidden_count":2"#,
11159            r#""route_shell_count":3"#,
11160            r#""route_count":4"#,
11161        ] {
11162            assert!(
11163                checkpoint.contains(state),
11164                "{state} state missing: {checkpoint}"
11165            );
11166        }
11167        assert!(
11168            !checkpoint.contains("unrelated"),
11169            "checkpoint leaked unrelated state: {checkpoint}"
11170        );
11171        assert!(
11172            checkpoint.contains(
11173                r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
11174            ),
11175            "checkpoint metadata lost source order: {checkpoint}"
11176        );
11177    }
11178
11179    #[test]
11180    fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
11181        // Three boundaries: comp-a, comp-b, then comp-a reused. Each checkpoint
11182        // envelope must be locally scoped — templates/state for exactly the
11183        // components rendered since the previous checkpoint, with reused tags
11184        // carrying hydration state but never a duplicate template.
11185        let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
11186        let handler = WebUIHandler::with_plugin(|| {
11187            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11188        });
11189        let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
11190        let mut writer = FlushTestWriter::default();
11191        handler
11192            .render_streaming(
11193                &protocol,
11194                &state,
11195                &RenderOptions::new("index.html", "/"),
11196                &mut writer,
11197            )
11198            .unwrap();
11199
11200        assert_eq!(
11201            writer.flushes.len(),
11202            4,
11203            "three boundary commits plus one terminal"
11204        );
11205        let segment = |index: usize| -> &str {
11206            let start = if index == 0 {
11207                0
11208            } else {
11209                writer.flushes[index - 1]
11210            };
11211            &writer.output[start..writer.flushes[index]]
11212        };
11213        let b0 = segment(0);
11214        let b1 = segment(1);
11215        let b2 = segment(2);
11216
11217        // Boundary 0: only comp-a's template/state; no comp-b artifacts.
11218        assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
11219        assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
11220        assert!(b0.contains(r#"[1,0,0,0,{"inventory":"01""#), "b0: {b0}");
11221        assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
11222        assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
11223
11224        // Boundary 1: only comp-b's template/state; no duplicate comp-a template.
11225        assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
11226        assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
11227        assert!(b1.contains(r#"[1,1,0,1,{"inventory":"02""#), "b1: {b1}");
11228        assert!(
11229            !b1.contains(r#""templates":{"comp-a"#),
11230            "b1 re-sent comp-a: {b1}"
11231        );
11232        assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
11233
11234        // Boundary 2: comp-a reused — state present, template absent (empty delta).
11235        assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
11236        assert!(b2.contains(r#"[1,2,0,2,{"inventory":"""#), "b2: {b2}");
11237        assert!(
11238            !b2.contains(r#""templates""#),
11239            "b2 re-sent a template: {b2}"
11240        );
11241
11242        // Server-only state never leaks into any envelope.
11243        assert!(!writer.output.contains("serverOnly"));
11244    }
11245
11246    #[test]
11247    fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
11248        let entry = vec![
11249            WebUIFragment::raw("<html><head>"),
11250            structural_fragment("head_start"),
11251            structural_fragment("head_end"),
11252            WebUIFragment::raw("</head><body>"),
11253            structural_fragment("body_start"),
11254            structural_fragment("boundary_start:0"),
11255            WebUIFragment::raw("<comp-a"),
11256            structural_fragment("streaming_root:comp-a"),
11257            WebUIFragment::raw(">"),
11258            WebUIFragment::component("comp-a"),
11259            WebUIFragment::raw("</comp-a>"),
11260            structural_fragment("boundary_end:0"),
11261            structural_fragment("boundary_start:1"),
11262            WebUIFragment::raw("<comp-hidden"),
11263            structural_fragment("streaming_root:comp-hidden"),
11264            WebUIFragment::raw(">"),
11265            WebUIFragment::component("comp-hidden"),
11266            WebUIFragment::raw("</comp-hidden>"),
11267            structural_fragment("boundary_end:1"),
11268            structural_fragment("body_end"),
11269            WebUIFragment::raw("</body></html>"),
11270        ];
11271        let fragments = HashMap::from([
11272            ("index.html".to_string(), FragmentList { fragments: entry }),
11273            (
11274                "comp-a".to_string(),
11275                FragmentList {
11276                    fragments: vec![WebUIFragment::if_cond(
11277                        ConditionExpr::identifier("show_hidden"),
11278                        "hidden-if",
11279                    )],
11280                },
11281            ),
11282            (
11283                "hidden-if".to_string(),
11284                FragmentList {
11285                    fragments: vec![WebUIFragment::component("comp-hidden")],
11286                },
11287            ),
11288            (
11289                "comp-hidden".to_string(),
11290                FragmentList {
11291                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
11292                },
11293            ),
11294        ]);
11295        let mut document = WebUIProtocol::new(fragments);
11296        document.initial_state_strategy = InitialStateStrategy::Components as i32;
11297        document.components.insert(
11298            "comp-a".to_string(),
11299            webui_protocol::ComponentData {
11300                template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
11301                hydration_mode: StateProjectionMode::Keys as i32,
11302                hydration_keys: vec!["show_hidden".to_string()],
11303                ..Default::default()
11304            },
11305        );
11306        document.components.insert(
11307            "comp-hidden".to_string(),
11308            webui_protocol::ComponentData {
11309                template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
11310                css: ".hidden{display:block}".to_string(),
11311                hydration_mode: StateProjectionMode::Keys as i32,
11312                hydration_keys: vec!["hidden_count".to_string()],
11313                ..Default::default()
11314            },
11315        );
11316        let protocol = Protocol::new(document);
11317        let handler = WebUIHandler::with_plugin(|| {
11318            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11319        });
11320        let mut writer = FlushTestWriter::default();
11321
11322        handler
11323            .render_streaming(
11324                &protocol,
11325                &test_json!({
11326                    "show_hidden": false,
11327                    "hidden_count": 7,
11328                    "unrelated": "private",
11329                }),
11330                &RenderOptions::new("index.html", "/"),
11331                &mut writer,
11332            )
11333            .unwrap();
11334
11335        let boundary = |index: usize| -> &str {
11336            let start = if index == 0 {
11337                0
11338            } else {
11339                writer.flushes[index - 1]
11340            };
11341            &writer.output[start..writer.flushes[index]]
11342        };
11343        let first = boundary(0);
11344        let second = boundary(1);
11345
11346        assert!(first.contains(r#""comp-a":"#), "first: {first}");
11347        assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
11348        assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
11349        assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
11350        assert!(first.contains(r#""inventory":"01""#), "first: {first}");
11351        assert!(
11352            first.contains(r#"<script type="importmap">"#),
11353            "first: {first}"
11354        );
11355        assert!(
11356            !first.contains("unrelated"),
11357            "first leaked unrelated state: {first}"
11358        );
11359
11360        assert!(
11361            !second.contains(r#""templates""#),
11362            "second re-sent metadata: {second}"
11363        );
11364        assert!(
11365            !second.contains(r#"<script type="importmap">"#),
11366            "second re-sent CSS: {second}"
11367        );
11368        assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
11369        assert!(second.contains(r#""inventory":"02""#), "second: {second}");
11370        assert_eq!(
11371            writer
11372                .output
11373                .matches(r#"<script type="importmap">"#)
11374                .count(),
11375            1
11376        );
11377    }
11378
11379    #[test]
11380    fn streaming_flush_count_is_boundaries_plus_one() {
11381        for &count in &[1usize, 3, 10, 100] {
11382            let hosts: Vec<&str> = (0..count)
11383                .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
11384                .collect();
11385            let protocol = disjoint_streaming_protocol(&hosts);
11386            let handler = WebUIHandler::with_plugin(|| {
11387                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11388            });
11389            let mut writer = FlushTestWriter::default();
11390            handler
11391                .render_streaming(
11392                    &protocol,
11393                    &test_json!({ "a_count": 1, "b_count": 2 }),
11394                    &RenderOptions::new("index.html", "/"),
11395                    &mut writer,
11396                )
11397                .unwrap();
11398            assert_eq!(
11399                writer.flushes.len(),
11400                count + 1,
11401                "expected boundaries+1 flushes for {count} boundaries"
11402            );
11403        }
11404    }
11405
11406    #[test]
11407    fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
11408        let mut protocol = WebUIProtocol::new(HashMap::new());
11409        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11410        protocol.components.insert(
11411            "comp-a".to_string(),
11412            webui_protocol::ComponentData {
11413                hydration_mode: StateProjectionMode::Keys as i32,
11414                hydration_keys: vec!["a".to_string(), "shared".to_string()],
11415                ..Default::default()
11416            },
11417        );
11418        protocol.components.insert(
11419            "comp-b".to_string(),
11420            webui_protocol::ComponentData {
11421                hydration_mode: StateProjectionMode::Keys as i32,
11422                hydration_keys: vec!["b".to_string(), "shared".to_string()],
11423                ..Default::default()
11424            },
11425        );
11426
11427        let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
11428        assert!(!collect_hydration_state_into(
11429            &protocol,
11430            ["comp-a"],
11431            &mut scratch,
11432        ));
11433        assert_eq!(scratch, ["a", "shared"]);
11434        let pointer = scratch.as_ptr();
11435        let capacity = scratch.capacity();
11436
11437        assert!(!collect_hydration_state_into(
11438            &protocol,
11439            ["comp-b"],
11440            &mut scratch,
11441        ));
11442        assert_eq!(scratch, ["b", "shared"]);
11443        assert_eq!(scratch.as_ptr(), pointer);
11444        assert_eq!(scratch.capacity(), capacity);
11445    }
11446}