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;
12mod render_capacity;
13mod response_writer;
14pub mod route_handler;
15pub mod route_matcher;
16pub(crate) mod route_renderer;
17pub(crate) mod streaming;
18
19#[doc(hidden)]
20pub use render_capacity::RenderCapacityHints;
21#[doc(hidden)]
22pub use response_writer::{
23    append_attribute_to_bytes, append_attribute_to_string, append_boolean_attribute_to_bytes,
24    append_boolean_attribute_to_string,
25};
26pub use route_handler::Protocol;
27
28/// Minimal HTML escaper for the 6 XSS-critical characters
29/// (`& < > " ' /`). Returns `Cow::Borrowed` when no escaping is
30/// needed (zero allocation on the happy path), `Cow::Owned` when
31/// any character had to be replaced.
32///
33/// Re-exported here so external callers of `RenderOptions::with_head_inject`
34/// / `with_body_inject` can pre-escape untrusted content with the
35/// same escaper the handler uses internally for SSR text content,
36/// without having to pull in a separate HTML-escape crate.
37pub use html_encode::encode_safe;
38
39use plugin::BootstrapExtensionContext;
40use plugin::HandlerPlugin;
41use plugin::WebUiTemplatePayload;
42use route_handler::ComponentReachabilityIndex;
43use route_matcher::CompiledRouteIndex;
44use serde::ser::SerializeMap;
45use serde::Serialize;
46use serde_json::Value;
47use std::borrow::Cow;
48use std::cell::{Cell, OnceCell};
49use std::collections::{HashMap, HashSet};
50use std::fmt::{self, Write as _};
51use std::hash::{BuildHasherDefault, Hasher};
52use std::sync::{Arc, LazyLock};
53use streaming::{
54    consume_streaming_component_root, ensure_no_pending_streaming_root,
55    prepare_generated_streaming_root, record_checkpoint_tag, streaming_template_already_sent,
56    validate_pending_streaming_root, validate_streaming_root_opening, ComponentHostOrigin,
57    StreamingRenderState,
58};
59pub use streaming::{
60    BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, BufferSink, SessionOptions,
61    SpanInstanceId, StreamStatus, StreamStep, StreamingResponse, StreamingSession, StreamingState,
62    MAX_BOUNDARY_OCCURRENCES, MAX_CONTINUATION_DEPTH, MAX_KEYED_INSTANCES, MAX_SPAN_NESTING,
63};
64use thiserror::Error;
65use webui_expressions::{evaluate_with_resolver, ExpressionError};
66use webui_protocol::{
67    web_ui_fragment::Fragment, ComponentAssetStylePreload, ComponentWorkPolicy, FragmentList,
68    InitialStateStrategy, StateProjectionMode, WebUIFragment, WebUIProtocol,
69};
70use webui_state::find_value_by_dotted_path_ref;
71
72/// Error types for the WebUI handler.
73#[derive(Debug, Error)]
74pub enum HandlerError {
75    #[error("Rendering error: {0}")]
76    Rendering(String),
77
78    /// Host-supplied state JSON could not be parsed or validated.
79    ///
80    /// This is a caller input error, not a render failure. Bindings map it to
81    /// their own state-error type, so it must stay distinguishable from
82    /// [`HandlerError::Rendering`] without inspecting the message text.
83    #[error("invalid state JSON: {0}")]
84    InvalidState(String),
85
86    #[error("Rendering invariant error: {0}")]
87    Invariant(String),
88
89    #[error("Missing fragment: {0}")]
90    MissingFragment(String),
91
92    #[error("Missing data field: {0}")]
93    MissingData(String),
94
95    #[error("Type error: {0}")]
96    TypeError(String),
97
98    #[error("Protocol error: {0}")]
99    Protocol(#[from] webui_protocol::ProtocolError),
100
101    #[error("Evaluation error: {0}")]
102    Evaluation(String),
103
104    #[error("I/O error: {0}")]
105    Io(#[from] std::io::Error),
106
107    #[error("Writer error: {0}")]
108    Writer(String),
109
110    #[error("Plugin data error: {0}")]
111    PluginData(String),
112
113    /// The HTTP client disconnected before the render completed.
114    ///
115    /// Streaming `ResponseWriter` implementations return this from
116    /// `write()` once their channel/socket is closed, so the handler
117    /// can abort the render rather than do CPU work that has nowhere
118    /// to go. Allocation-free (the variant carries no payload).
119    #[error("client disconnected")]
120    ClientDisconnected,
121
122    /// The streaming writer's flush exceeded its configured deadline.
123    ///
124    /// Indicates a slow/unresponsive consumer (slow-loris client,
125    /// stuck proxy, etc.). The render thread is freed; downstream
126    /// telemetry should distinguish this from `ClientDisconnected`
127    /// so ops can alert on slow-client attacks.
128    #[error("streaming flush timed out")]
129    StreamTimeout,
130
131    /// A streaming-boundary signal was malformed or arrived out of order.
132    ///
133    /// The payload is boxed so this cold, streaming-only variant does not
134    /// widen [`HandlerError`] — and therefore `Result<(), HandlerError>`
135    /// threaded through the entire hot legacy render path — with two inline
136    /// `String`s.
137    #[error("invalid streaming boundary signal `{}`: {}", .0.signal, .0.reason)]
138    StreamingBoundary(Box<StreamingBoundaryError>),
139
140    /// A streaming render ended without its structural `body_end` signal.
141    #[error("streaming render ended before `body_end`; no terminal boundary record was emitted")]
142    MissingStreamingBodyEnd,
143
144    /// Streaming initialization cannot be emitted before document content.
145    #[error(
146        "streaming protocol is missing the required `head_start` signal before `{before}`; \
147         rebuild the protocol with streaming-boundary parser support"
148    )]
149    MissingStreamingHeadStart {
150        /// Structural point that made initialization too late.
151        before: &'static str,
152    },
153
154    /// A malformed protocol emitted streaming initialization more than once.
155    #[error("streaming protocol emitted duplicate `head_start` signals")]
156    DuplicateStreamingHeadStart,
157}
158
159/// Boxed payload for [`HandlerError::StreamingBoundary`].
160///
161/// Kept behind a `Box` so the streaming-validation variant contributes only a
162/// pointer-sized payload to [`HandlerError`], keeping the common
163/// `Result<(), HandlerError>` small on the ordinary render path where these
164/// boundary errors never occur.
165#[derive(Debug)]
166pub struct StreamingBoundaryError {
167    /// The logical structural token (the internal namespace is stripped).
168    pub signal: String,
169    /// Actionable validation failure.
170    pub reason: String,
171}
172
173pub type Result<T> = std::result::Result<T, HandlerError>;
174
175#[cold]
176#[inline(never)]
177fn invalid_fragment_range_error(
178    range: &std::ops::Range<usize>,
179    fragment_count: usize,
180) -> HandlerError {
181    HandlerError::Invariant(format!(
182        "fragment range {}..{} exceeds fragment count {fragment_count}",
183        range.start, range.end
184    ))
185}
186
187#[cold]
188#[inline(never)]
189fn route_style_plan_missing_error() -> HandlerError {
190    HandlerError::Invariant(
191        "matched route style targets were computed without a route chain".to_string(),
192    )
193}
194
195#[cold]
196#[inline(never)]
197fn route_style_plan_length_error(routes: usize, targets: usize) -> HandlerError {
198    HandlerError::Invariant(format!(
199        "matched route style target count {targets} does not match route count {routes}"
200    ))
201}
202
203/// Interface for writing rendered output
204pub trait ResponseWriter {
205    /// Write content to the output
206    fn write(&mut self, content: &str) -> Result<()>;
207
208    /// Write one complete quoted HTML attribute.
209    #[doc(hidden)]
210    fn write_attribute(&mut self, name: &str, value: &str) -> Result<()> {
211        self.write(" ")?;
212        self.write(name)?;
213        self.write("=\"")?;
214        self.write(value)?;
215        self.write("\"")
216    }
217
218    /// Write one complete boolean HTML attribute.
219    #[doc(hidden)]
220    fn write_boolean_attribute(&mut self, name: &str) -> Result<()> {
221        self.write(" ")?;
222        self.write(name)
223    }
224
225    /// Finalize the output
226    fn end(&mut self) -> Result<()>;
227
228    /// Hand buffered bytes to the transport at a committed streaming boundary.
229    ///
230    /// Internal hook used only by the progressive streaming render path so the
231    /// request-local sink can flush its concrete transport without a shared-cell
232    /// borrow or a second virtual dispatch on every write. Ordinary writers keep
233    /// the no-op default; hosts must not override or depend on this — implement
234    /// [`FlushWriter`] and use [`WebUIHandler::render_streaming`] instead.
235    #[doc(hidden)]
236    fn stream_flush(&mut self) -> Result<()> {
237        Ok(())
238    }
239
240    #[doc(hidden)]
241    fn stream_begin_component(&mut self) -> Result<()> {
242        Err(HandlerError::Invariant(
243            "component opening buffering requires a streaming sink".to_string(),
244        ))
245    }
246
247    #[doc(hidden)]
248    fn stream_mark_component_root(&mut self) -> Result<()> {
249        Err(HandlerError::Invariant(
250            "component root buffering requires a streaming sink".to_string(),
251        ))
252    }
253
254    #[doc(hidden)]
255    fn stream_commit_component(
256        &mut self,
257        span_id: Option<u32>,
258        enclosing_span_id: Option<u32>,
259        deferred: bool,
260    ) -> Result<()> {
261        let _ = (span_id, enclosing_span_id, deferred);
262        Err(HandlerError::Invariant(
263            "component opening commit requires a streaming sink".to_string(),
264        ))
265    }
266}
267
268/// Serialize every application state key except a sorted base projection.
269///
270/// Streaming uses this only when a prior keyed projection is a proven subset
271/// of the current full-state projection. The browser merges the emitted delta
272/// over that exact prior range state.
273struct StateWithoutSelectedKeys<'a> {
274    value: &'a Value,
275    keys: KeyView<'a>,
276}
277
278impl Serialize for StateWithoutSelectedKeys<'_> {
279    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
280    where
281        S: serde::Serializer,
282    {
283        let Value::Object(map) = self.value else {
284            return serializer.serialize_map(Some(0))?.end();
285        };
286        let mut out = serializer.serialize_map(None)?;
287        for (key, value) in map {
288            if key != STATE_INJECT_KEY && !self.keys.contains(key) {
289                out.serialize_entry(key, value)?;
290            }
291        }
292        out.end()
293    }
294}
295
296/// A response writer that can hand buffered bytes to its transport immediately.
297///
298/// Progressive hydration requires this semantic flush at every committed
299/// boundary. Hosts that cannot provide it must use [`WebUIHandler::render`].
300pub trait FlushWriter: ResponseWriter {
301    /// Hand all currently buffered bytes to the underlying transport.
302    fn flush(&mut self) -> Result<()>;
303}
304
305/// Options controlling how the handler renders a protocol.
306///
307/// The handler performs server-side route matching: matched routes are rendered
308/// visible with content; non-matched routes are rendered hidden and empty.
309pub struct RenderOptions<'a> {
310    /// The fragment ID to start rendering from (e.g., `"index.html"`).
311    pub entry_id: &'a str,
312    /// The URL path to match routes against (e.g., `"/contacts/42"`).
313    pub request_path: &'a str,
314    /// Optional CSP nonce for inline `<script>` tags.
315    /// When set, all inline scripts include `nonce="VALUE"` and a
316    /// `<meta name="webui-nonce">` tag is emitted for the client router.
317    pub nonce: Option<&'a str>,
318    /// Optional HTML to emit immediately before the document's
319    /// `</head>` close. Used for per-request `<link rel="preload">`
320    /// hints, CSP `<meta>` tags beyond the built-in nonce, etc.
321    /// Inserted at the structural `head_end` boundary identified by
322    /// the parser — never matched against a byte pattern, so cannot
323    /// be tricked by `</head>` literals appearing in HTML comments,
324    /// `srcdoc` attributes, or inline scripts.
325    pub head_inject: Option<&'a str>,
326    /// Optional HTML to emit immediately before the document's
327    /// `</body>` close. Used for dev livereload `<script>`, analytics
328    /// snippets, OpenTelemetry trace IDs, etc.
329    /// Same structural-boundary guarantee as [`head_inject`](Self::head_inject).
330    pub body_inject: Option<&'a str>,
331}
332
333/// Reserved top-level state key carrying host-supplied boundary HTML.
334///
335/// The leading `$` marks this key as reserved host metadata rather than
336/// ordinary application state.
337///
338/// Recognized members, each an optional string:
339///
340/// | Member | Emitted at |
341/// | --- | --- |
342/// | `headEnd` | immediately before `</head>` |
343/// | `bodyStart` | immediately after `<body>` |
344/// | `bodyEnd` | immediately before `</body>` |
345///
346/// The key is always honored — it is part of the render state the host
347/// already supplies — and is stripped from the client hydration payload.
348pub const STATE_INJECT_KEY: &str = "$webui";
349
350/// Host-supplied boundary HTML resolved once per render from
351/// [`STATE_INJECT_KEY`].
352///
353/// Resolution happens when the render context is built, so each structural
354/// hook costs one `Option` check instead of a state map lookup. Every field
355/// borrows from the caller's state value — no clone, no allocation.
356#[derive(Clone, Copy, Default)]
357pub(crate) struct StateInject<'state> {
358    pub(crate) head_end: Option<&'state str>,
359    pub(crate) body_start: Option<&'state str>,
360    pub(crate) body_end: Option<&'state str>,
361}
362
363impl<'state> StateInject<'state> {
364    /// Resolve the reserved namespace from a render state value.
365    ///
366    /// Returns the empty set when the key is absent, or when any member is
367    /// missing, null, empty, or not a string. Malformed input is inert rather than an error: the reserved
368    /// key is an optional side channel, and a render must not fail because a
369    /// host wrote the wrong shape into it.
370    pub(crate) fn resolve(state: &'state Value) -> Self {
371        let Some(Value::Object(map)) = state.get(STATE_INJECT_KEY) else {
372            return Self::default();
373        };
374        let field = |name: &str| {
375            map.get(name)
376                .and_then(Value::as_str)
377                .filter(|html| !html.is_empty())
378        };
379        Self {
380            head_end: field("headEnd"),
381            body_start: field("bodyStart"),
382            body_end: field("bodyEnd"),
383        }
384    }
385}
386
387impl<'a> RenderOptions<'a> {
388    /// Create render options for the given entry fragment and request path.
389    #[must_use]
390    pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
391        Self {
392            entry_id,
393            request_path,
394            nonce: None,
395            head_inject: None,
396            body_inject: None,
397        }
398    }
399
400    /// Set the CSP nonce for inline scripts. Pass an empty string to
401    /// disable (`None` semantics) — empty `<meta name="webui-nonce"
402    /// content="">` would be browser-ignored noise.
403    #[must_use]
404    pub fn with_nonce(mut self, nonce: &'a str) -> Self {
405        self.nonce = if nonce.is_empty() { None } else { Some(nonce) };
406        self
407    }
408
409    /// Set HTML to emit immediately before `</head>`.
410    /// Pass an empty string to disable (`None` semantics).
411    ///
412    /// # Safety (XSS warning)
413    ///
414    /// The provided HTML is written verbatim — **no HTML escaping is
415    /// performed**. Callers MUST ensure the content is fully trusted
416    /// (typically a `&'static str` or build-time-derived bytes such as
417    /// dev livereload script, image preload `<link>` tags, or A/B test
418    /// markers). Passing user-controlled or attacker-influenced content
419    /// here is a direct cross-site scripting vulnerability. If your
420    /// caller path may include untrusted data, escape with the host's
421    /// HTML escaper (e.g. [`webui_handler::encode_safe`](crate::encode_safe))
422    /// **before** calling this builder.
423    #[must_use]
424    pub fn with_head_inject(mut self, html: &'a str) -> Self {
425        self.head_inject = if html.is_empty() { None } else { Some(html) };
426        self
427    }
428
429    /// Set HTML to emit immediately before `</body>`.
430    /// Pass an empty string to disable (`None` semantics).
431    ///
432    /// # Safety (XSS warning)
433    ///
434    /// Same contract as [`with_head_inject`](Self::with_head_inject):
435    /// the HTML is written verbatim with **no escaping**, so callers
436    /// MUST ensure the content is fully trusted. Untrusted content is
437    /// a direct XSS vector.
438    #[must_use]
439    pub fn with_body_inject(mut self, html: &'a str) -> Self {
440        self.body_inject = if html.is_empty() { None } else { Some(html) };
441        self
442    }
443}
444
445/// The main WebUI handler that processes protocols and renders them.
446///
447/// The handler is stateless: plugin instances are created per-render from
448/// the stored factory function, allowing concurrent renders with `&self`.
449pub struct WebUIHandler {
450    plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
451}
452
453#[derive(Clone, Copy, Eq, PartialEq)]
454enum StyleClosureInstall {
455    Static,
456    Routed,
457}
458
459pub(crate) struct ShadowStyleRoot {
460    component_index: u32,
461    static_closure_emitted: bool,
462    routed_resources: Vec<u32>,
463}
464
465struct LoopBinding<'protocol, 'state> {
466    name: &'protocol str,
467    value: &'state Value,
468}
469
470#[derive(Clone, Copy)]
471struct VisibleLoopScope {
472    start: usize,
473    end: usize,
474}
475
476impl VisibleLoopScope {
477    const EMPTY: Self = Self { start: 0, end: 0 };
478}
479
480/// Map backing a render scope: component props or loop/local variables.
481pub(crate) type ScopeMap = HashMap<String, Value, BuildHasherDefault<ScopeKeyHasher>>;
482
483/// Multiply-rotate hasher (the FxHash scheme used by rustc) for [`ScopeMap`].
484///
485/// Scope keys are prop and variable names from the compiled protocol, never
486/// request data, so SipHash's flooding resistance buys nothing here. Large
487/// pages insert and probe these maps tens of thousands of times per render,
488/// where SipHash was a measurable share of render time.
489#[derive(Clone, Copy, Default)]
490pub(crate) struct ScopeKeyHasher(u64);
491
492impl ScopeKeyHasher {
493    const SEED: u64 = 0x51_7c_c1_b7_27_22_0a_95;
494
495    #[inline]
496    fn add_word(&mut self, word: u64) {
497        self.0 = (self.0.rotate_left(5) ^ word).wrapping_mul(Self::SEED);
498    }
499}
500
501impl Hasher for ScopeKeyHasher {
502    #[inline]
503    fn write(&mut self, bytes: &[u8]) {
504        let (words, tail) = bytes.as_chunks::<8>();
505        for word in words {
506            self.add_word(u64::from_le_bytes(*word));
507        }
508        if !tail.is_empty() {
509            let mut word = [0u8; 8];
510            word[..tail.len()].copy_from_slice(tail);
511            self.add_word(u64::from_le_bytes(word));
512        }
513    }
514
515    #[inline]
516    fn write_u8(&mut self, byte: u8) {
517        self.add_word(u64::from(byte));
518    }
519
520    #[inline]
521    fn finish(&self) -> u64 {
522        self.0
523    }
524}
525
526#[derive(Clone, Copy)]
527struct LocalValueSources<'ctx, 'protocol, 'state> {
528    owned: &'ctx ScopeMap,
529    borrowed: &'ctx BorrowedScope<'protocol, 'state>,
530}
531
532/// Route level an `<outlet />` matches against.
533pub(crate) type RouteChildren<'protocol> = Cow<'protocol, [webui_protocol::WebUiFragmentRoute]>;
534
535#[derive(Default)]
536struct BorrowedScope<'protocol, 'state> {
537    inline: [Option<(&'protocol str, &'state Value)>; INLINE_SCOPE_SLOTS],
538    inline_len: usize,
539    overflow: Vec<(&'protocol str, &'state Value)>,
540}
541
542impl<'protocol, 'state> BorrowedScope<'protocol, 'state> {
543    fn get(&self, name: &str) -> Option<&'state Value> {
544        if self.inline_len == 0 {
545            return None;
546        }
547        if let Some((entry_name, value)) = self.inline[0].as_ref() {
548            if *entry_name == name {
549                return Some(*value);
550            }
551        }
552        for (entry_name, value) in self.inline[1..self.inline_len].iter().flatten() {
553            if *entry_name == name {
554                return Some(*value);
555            }
556        }
557        self.overflow
558            .iter()
559            .find_map(|(entry_name, value)| (*entry_name == name).then_some(*value))
560    }
561
562    fn insert(&mut self, name: &'protocol str, value: &'state Value) -> Option<&'state Value> {
563        for (entry_name, current) in self.inline[..self.inline_len].iter_mut().flatten() {
564            if *entry_name == name {
565                return Some(std::mem::replace(current, value));
566            }
567        }
568        for (entry_name, current) in &mut self.overflow {
569            if *entry_name == name {
570                return Some(std::mem::replace(current, value));
571            }
572        }
573        if self.inline_len < INLINE_SCOPE_SLOTS {
574            self.inline[self.inline_len] = Some((name, value));
575            self.inline_len += 1;
576        } else {
577            self.overflow.push((name, value));
578        }
579        None
580    }
581
582    fn remove(&mut self, name: &str) -> Option<&'state Value> {
583        if let Some(index) = self.inline[..self.inline_len].iter().position(|entry| {
584            entry
585                .as_ref()
586                .is_some_and(|(entry_name, _)| *entry_name == name)
587        }) {
588            let removed = self.inline[index].take().map(|(_, value)| value);
589            if let Some(entry) = self.overflow.pop() {
590                self.inline[index] = Some(entry);
591            } else {
592                self.inline_len -= 1;
593                self.inline[index] = self.inline[self.inline_len].take();
594            }
595            return removed;
596        }
597        let index = self
598            .overflow
599            .iter()
600            .position(|(entry_name, _)| *entry_name == name)?;
601        Some(self.overflow.swap_remove(index).1)
602    }
603
604    fn clear(&mut self) {
605        for entry in &mut self.inline[..self.inline_len] {
606            *entry = None;
607        }
608        self.inline_len = 0;
609        self.overflow.clear();
610    }
611
612    fn clone_into_owned(&self, target: &mut ScopeMap) {
613        for (name, value) in self.inline[..self.inline_len].iter().flatten() {
614            target.insert((*name).to_owned(), (*value).clone());
615        }
616        for (name, value) in &self.overflow {
617            target.insert((*name).to_owned(), (*value).clone());
618        }
619    }
620}
621
622/// A fragment list paired with the render metadata prepared for it when the
623/// runtime [`Protocol`] was loaded.
624///
625/// `fragments` still points at the protocol's own storage, so passing this by
626/// value never copies or clones a fragment graph.
627#[derive(Clone, Copy)]
628pub(crate) struct RenderFragmentList<'protocol> {
629    fragments: &'protocol [WebUIFragment],
630    metadata: &'protocol [RenderFragmentMetadata],
631    attr_names: &'protocol str,
632    contains_boundary: bool,
633    /// True when this list contains at least one `<webui-route>` fragment.
634    /// Renders skip the sibling route pre-scan entirely when it is false.
635    has_routes: bool,
636}
637
638impl<'protocol> RenderFragmentList<'protocol> {
639    /// Render slot the fragment at `index` descends into, if any.
640    fn target(self, index: usize) -> Option<usize> {
641        let target = self.metadata.get(index)?.target;
642        (target != NO_RENDER_SLOT).then_some(target as usize)
643    }
644
645    /// Canonical camelCase component prop name prepared for an attribute fragment.
646    fn component_attr_name(self, index: usize) -> Option<&'protocol str> {
647        let prepared = self.metadata.get(index)?;
648        if prepared.attr_start == NO_ATTR_NAME {
649            return None;
650        }
651        let start = prepared.attr_start as usize;
652        self.attr_names
653            .get(start..start + prepared.attr_len as usize)
654    }
655}
656
657/// Sentinel for "this fragment does not descend into another fragment list".
658const NO_RENDER_SLOT: u32 = u32::MAX;
659/// Sentinel for "this fragment is not an attribute fragment".
660const NO_ATTR_NAME: u32 = u32::MAX;
661
662/// Per-fragment values hoisted out of the render loop at protocol load time.
663///
664/// Deliberately a flat 12-byte `Copy` record with no owned allocations: a large
665/// protocol keeps one contiguous arena instead of one heap block per fragment.
666#[derive(Clone, Copy)]
667struct RenderFragmentMetadata {
668    /// Numeric slot of the fragment list this fragment renders into, avoiding a
669    /// string hash lookup per component, loop, condition, and template attribute.
670    /// [`NO_RENDER_SLOT`] when the fragment renders inline.
671    target: u32,
672    /// Offset into the index's shared attribute-name arena, or [`NO_ATTR_NAME`].
673    attr_start: u32,
674    attr_len: u32,
675}
676
677/// Build-time render plan for every fragment list in a protocol.
678///
679/// Built once when a [`Protocol`] is created and shared immutably by every
680/// render. Fragment IDs are the same `Arc<str>` values the protocol already
681/// interns, metadata lives in one flat arena, and the fragment graphs
682/// themselves are never duplicated.
683pub(crate) struct RenderFragmentIndex {
684    ids: Box<[Arc<str>]>,
685    metadata: Box<[RenderFragmentMetadata]>,
686    /// Prefix offsets into `metadata`; length is `ids.len() + 1`.
687    ranges: Box<[u32]>,
688    /// Every prepared component prop name concatenated into one allocation.
689    attr_names: Box<str>,
690    /// One bit per fragment list: does it contain a route fragment?
691    route_presence: Box<[u64]>,
692}
693
694/// A [`RenderFragmentIndex`] bound to the protocol document for one render.
695///
696/// Fragment lists are borrowed lazily and memoized in an inline slot cache, so
697/// a large protocol is never eagerly walked to serve a small render.
698pub(crate) struct ResolvedRenderFragmentIndex<'protocol> {
699    cache: [Cell<Option<&'protocol FragmentList>>; INLINE_RENDER_FRAGMENT_LISTS],
700    /// Memo slots for protocols with more fragment lists than the inline cache
701    /// holds, allocated on first use so a small render never pays for it and a
702    /// large one never falls back to hashing the fragment ID on every descent.
703    spilled: OnceCell<Box<[Cell<Option<&'protocol FragmentList>>]>>,
704    index: &'protocol RenderFragmentIndex,
705    protocol: &'protocol WebUIProtocol,
706}
707
708impl RenderFragmentIndex {
709    /// Prepare the render plan for `protocol`.
710    ///
711    /// `ids` must be the protocol's sorted fragment IDs and `slots` its ID→slot
712    /// map, so render slots and the runtime [`Protocol`]'s continuation slots are
713    /// the same numbering. Reusing the map keeps preparation to one hash lookup
714    /// per reference instead of a string binary search.
715    pub(crate) fn new(
716        protocol: &WebUIProtocol,
717        ids: &[Arc<str>],
718        slots: &HashMap<Arc<str>, u32>,
719    ) -> Self {
720        let total_fragments = protocol
721            .fragments
722            .values()
723            .map(|list| list.fragments.len())
724            .sum();
725        let mut metadata = Vec::with_capacity(total_fragments);
726        let mut ranges = Vec::with_capacity(ids.len() + 1);
727        // Most attribute names are short; reserving up front keeps the shared
728        // arena from repeatedly reallocating and copying as it is filled.
729        let mut attr_names = String::with_capacity(total_fragments * 8);
730        let mut route_presence = vec![0u64; ids.len().div_ceil(64)];
731
732        for (slot, id) in ids.iter().enumerate() {
733            // Record counts are bounded by the compiled graph, well inside u32.
734            #[allow(clippy::cast_possible_truncation)]
735            ranges.push(metadata.len() as u32);
736            let Some(list) = protocol.fragments.get(id.as_ref()) else {
737                continue;
738            };
739            let mut has_routes = false;
740            for fragment in &list.fragments {
741                let inner = fragment.fragment.as_ref();
742                if matches!(inner, Some(Fragment::Route(_))) {
743                    has_routes = true;
744                }
745                let target = fragment_target_id(fragment)
746                    .and_then(|target| slots.get(target).copied())
747                    .unwrap_or(NO_RENDER_SLOT);
748                let (attr_start, attr_len) = match inner {
749                    // `attr_skip` attributes are never collected into component
750                    // props, so preparing a name for them would be pure load-time
751                    // cost for something no render ever reads.
752                    Some(Fragment::Attribute(attribute)) if !attribute.attr_skip => {
753                        #[allow(clippy::cast_possible_truncation)]
754                        let start = attr_names.len() as u32;
755                        let before = attr_names.len();
756                        push_component_attr_name(&mut attr_names, component_attr_source(attribute));
757                        #[allow(clippy::cast_possible_truncation)]
758                        let len = (attr_names.len() - before) as u32;
759                        (start, len)
760                    }
761                    _ => (NO_ATTR_NAME, 0),
762                };
763                metadata.push(RenderFragmentMetadata {
764                    target,
765                    attr_start,
766                    attr_len,
767                });
768            }
769            if has_routes {
770                route_presence[slot / 64] |= 1u64 << (slot % 64);
771            }
772        }
773        #[allow(clippy::cast_possible_truncation)]
774        ranges.push(metadata.len() as u32);
775
776        Self {
777            ids: ids.to_vec().into_boxed_slice(),
778            metadata: metadata.into_boxed_slice(),
779            ranges: ranges.into_boxed_slice(),
780            attr_names: attr_names.into_boxed_str(),
781            route_presence: route_presence.into_boxed_slice(),
782        }
783    }
784
785    /// Resolve a fragment ID to its stable numeric render slot.
786    pub(crate) fn index(&self, id: &str) -> Option<usize> {
787        self.ids
788            .binary_search_by(|candidate| candidate.as_ref().cmp(id))
789            .ok()
790    }
791
792    pub(crate) fn resolve<'protocol>(
793        &'protocol self,
794        protocol: &'protocol WebUIProtocol,
795    ) -> ResolvedRenderFragmentIndex<'protocol> {
796        ResolvedRenderFragmentIndex {
797            cache: std::array::from_fn(|_| Cell::new(None)),
798            spilled: OnceCell::new(),
799            index: self,
800            protocol,
801        }
802    }
803}
804
805impl<'protocol> ResolvedRenderFragmentIndex<'protocol> {
806    pub(crate) fn index(&self, id: &str) -> Option<usize> {
807        self.index.index(id)
808    }
809
810    /// Memo slot for `index`, spilling past the inline cache on demand.
811    fn slot(&self, index: usize) -> Option<&Cell<Option<&'protocol FragmentList>>> {
812        if index < INLINE_RENDER_FRAGMENT_LISTS {
813            return self.cache.get(index);
814        }
815        let spilled = self.spilled.get_or_init(|| {
816            let overflow = self
817                .index
818                .ids
819                .len()
820                .saturating_sub(INLINE_RENDER_FRAGMENT_LISTS);
821            (0..overflow).map(|_| Cell::new(None)).collect()
822        });
823        spilled.get(index - INLINE_RENDER_FRAGMENT_LISTS)
824    }
825
826    pub(crate) fn list(&self, index: usize) -> Option<RenderFragmentList<'protocol>> {
827        let id = self.index.ids.get(index)?;
828        let fragment_list = match self.slot(index) {
829            Some(slot) => match slot.get() {
830                Some(cached) => cached,
831                None => {
832                    let resolved = self.protocol.fragments.get(id.as_ref())?;
833                    slot.set(Some(resolved));
834                    resolved
835                }
836            },
837            None => self.protocol.fragments.get(id.as_ref())?,
838        };
839        let start = *self.index.ranges.get(index)? as usize;
840        let end = *self.index.ranges.get(index + 1)? as usize;
841        let metadata = self.index.metadata.get(start..end)?;
842        let has_routes = self
843            .index
844            .route_presence
845            .get(index / 64)
846            .is_some_and(|word| word & (1u64 << (index % 64)) != 0);
847        Some(RenderFragmentList {
848            fragments: &fragment_list.fragments,
849            metadata,
850            attr_names: &self.index.attr_names,
851            contains_boundary: fragment_list.contains_boundary,
852            has_routes,
853        })
854    }
855
856    #[cfg(test)]
857    pub(crate) fn list_by_id(&self, id: &str) -> Option<RenderFragmentList<'protocol>> {
858        self.index(id).and_then(|index| self.list(index))
859    }
860}
861
862/// Name that a component prop is derived from for an attribute fragment.
863///
864/// The render path strips a leading `:` twice on the complex-value branch and
865/// once on every other branch. Every field that selects the branch is a
866/// build-time constant, so the choice is made here and the prepared name stays
867/// byte-identical to the per-render conversion it replaces.
868fn component_attr_source(attribute: &webui_protocol::WebUIFragmentAttribute) -> &str {
869    let takes_complex_branch = attribute.condition_tree.is_none()
870        && attribute.template.is_empty()
871        && !attribute.value.is_empty()
872        && !attribute.raw_value
873        && attribute.complex;
874    if takes_complex_branch {
875        attribute.name.strip_prefix(':').unwrap_or(&attribute.name)
876    } else {
877        &attribute.name
878    }
879}
880
881/// Route level one step below `children[index]`.
882///
883/// A borrowed level yields a borrowed sublevel, so descending through a route
884/// tree during a render never copies a `WebUiFragmentRoute`. A level a
885/// streaming continuation already materialized transfers its child level.
886fn descend_into<'protocol>(
887    children: &mut RouteChildren<'protocol>,
888    index: usize,
889) -> RouteChildren<'protocol> {
890    match children {
891        Cow::Borrowed(routes) => match routes.get(index) {
892            Some(route) => Cow::Borrowed(&route.children),
893            None => Cow::Borrowed(&[]),
894        },
895        Cow::Owned(routes) => match routes.get_mut(index) {
896            Some(route) => Cow::Owned(std::mem::take(&mut route.children)),
897            None => Cow::Borrowed(&[]),
898        },
899    }
900}
901
902/// Fragment ID a fragment descends into, or `None` when it renders inline.
903fn fragment_target_id(fragment: &WebUIFragment) -> Option<&str> {
904    match fragment.fragment.as_ref()? {
905        Fragment::Component(component) => Some(&component.fragment_id),
906        Fragment::ForLoop(for_loop) => Some(&for_loop.fragment_id),
907        Fragment::IfCond(if_cond) => Some(&if_cond.fragment_id),
908        Fragment::Attribute(attribute) if !attribute.template.is_empty() => {
909            Some(&attribute.template)
910        }
911        _ => None,
912    }
913}
914
915/// Context object for processing WebUI fragments
916pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> {
917    pub(crate) protocol: &'protocol WebUIProtocol,
918    /// Render plan prepared once when the runtime [`Protocol`] was loaded.
919    /// Fragment lists resolve through numeric slots instead of per-fragment
920    /// string hashing.
921    pub(crate) render_fragments: ResolvedRenderFragmentIndex<'protocol>,
922    /// Build-constant component asset style metadata serialized once when the
923    /// runtime [`Protocol`] is created.
924    pub(crate) component_asset_style_manifest: &'protocol str,
925    /// Deduplicated document-scoped component asset styles for Light DOM.
926    pub(crate) component_asset_style_links: &'protocol str,
927    pub(crate) state: &'state Value,
928    pub(crate) writer: &'output mut dyn ResponseWriter,
929    pub(crate) local_vars: ScopeMap,
930    /// Component-local values that still point into immutable request state.
931    local_borrowed_vars: BorrowedScope<'protocol, 'state>,
932    /// Borrowed loop bindings, in lexical order.
933    loop_vars: Vec<LoopBinding<'protocol, 'state>>,
934    /// Range of `loop_vars` visible to the current fragment scope. Component
935    /// bodies hide outer loop monikers while still allowing their own loops.
936    visible_loop_scope: VisibleLoopScope,
937    /// Accumulates component attribute values between attrStart and the component fragment.
938    pub(crate) component_attrs: ScopeMap,
939    /// State-backed component attributes accumulated without cloning.
940    component_borrowed_attrs: BorrowedScope<'protocol, 'state>,
941    /// True only while parser-produced component opening-tag attributes are
942    /// being accumulated. Native element attributes render directly and never
943    /// enter `component_attrs`.
944    collecting_component_attrs: bool,
945    /// URL path for server-side route matching. Borrowed from
946    /// `RenderOptions<'a>::request_path` — zero-copy.
947    pub(crate) request_path: &'protocol str,
948    /// Base path for resolving relative route paths (`./`).
949    /// Updated as the handler descends into nested matched routes.
950    /// `Cow` keeps the initial `"/"` literal zero-copy; nested-route
951    /// descent owns the recomputed path.
952    pub(crate) route_base: Cow<'protocol, str>,
953    /// Component names visited during rendering (for selective f-template emission
954    /// and CSS module dedup — only the first render of each component emits
955    /// its `<script type="importmap">` data-URI tag).
956    pub(crate) rendered_components: HashSet<String>,
957    /// Per-render plugin instance created from the handler's factory.
958    pub(crate) plugin: Option<Box<dyn HandlerPlugin>>,
959    /// Current position in the route tree for outlet-based rendering.
960    /// Contains the children of the currently matched route fragment.
961    ///
962    /// A matched route's `children` is a recursive prost subtree, so the
963    /// borrowed variant avoids deep-cloning the whole remaining route tree on
964    /// every matched route of every request. Only a suspended streaming
965    /// continuation, which outlives the borrow, materializes the owned variant.
966    pub(crate) route_children: RouteChildren<'protocol>,
967    /// Entry fragment ID — used to compute the initial inventory at head_end.
968    /// Borrowed from `RenderOptions<'a>::entry_id` — zero-copy.
969    pub(crate) entry_id: &'protocol str,
970    /// CSP nonce for inline `<script>` tags (None = no nonce attribute).
971    /// Borrowed from `RenderOptions<'a>::nonce` — zero-copy.
972    pub(crate) nonce: Option<&'protocol str>,
973    /// Component-name → bit-position map built once when the runtime
974    /// [`Protocol`] is created and shared by every render.
975    pub(crate) component_index: &'protocol HashMap<String, u32>,
976    /// Style-resource ID → request-local dedup bit built with [`Protocol`].
977    pub(crate) style_resource_index: &'protocol HashMap<String, u32>,
978    /// Inline style resources whose CSS contains a case-insensitive `</style`.
979    ///
980    /// Computed once when [`Protocol`] loads so repeated component instances can
981    /// write ordinary CSS without rescanning identical bytes on every request.
982    pub(crate) style_resources_requiring_escape: &'protocol HashSet<String>,
983    /// Component tag → covering bundle chunk, built once per render.
984    ///
985    /// Empty (and allocation-free) for unbundled builds. Every style delivery
986    /// path resolves closure members through this so they all agree on what a
987    /// chunk already ships.
988    pub(crate) style_chunk_index: HashMap<&'protocol str, u32>,
989    /// CSS strategy declared by the compiled protocol.
990    pub(crate) css_strategy: webui_protocol::CssStrategy,
991    /// HTML emitted at the structural `head_end` boundary (before
992    /// `</head>`), after the built-in nonce/CSS emissions.
993    /// Zero-copy borrow of the caller's `RenderOptions<'a>::head_inject`
994    /// (no per-render clone — saves an allocation when the host passes
995    /// a `&'static str` such as a dev livereload script).
996    pub(crate) head_inject: Option<&'protocol str>,
997    /// HTML emitted at the structural `body_end` boundary (before
998    /// `</body>`), after the built-in template metadata emissions.
999    /// Same zero-copy borrow as [`head_inject`](Self::head_inject).
1000    pub(crate) body_inject: Option<&'protocol str>,
1001    /// Host HTML resolved once per render from the reserved
1002    /// [`STATE_INJECT_KEY`] state namespace. Emitted after the built-in
1003    /// emissions at each structural boundary, and after the corresponding
1004    /// `RenderOptions` inject at `head_end` and `body_end`.
1005    pub(crate) state_inject: StateInject<'state>,
1006    /// Tracks whether the `head_end` hook has already fired in this
1007    /// render. Defends against malformed protocols that emit the
1008    /// signal more than once (e.g., a template with multiple `<head>`
1009    /// tags) — without this, host-supplied `head_inject` HTML, CSS
1010    /// resources, and the CSP `<meta>` nonce would be
1011    /// duplicated, which can be a CSP-bypass / cache-bloat vector.
1012    pub(crate) head_end_emitted: bool,
1013    /// Tracks whether the `body_start` hook has already fired in this
1014    /// render. Defends against malformed protocols emitting the signal
1015    /// twice — without this, state-supplied `bodyStart` HTML would be
1016    /// duplicated.
1017    pub(crate) body_start_emitted: bool,
1018    /// Tracks whether compiler-owned component asset styles were emitted at
1019    /// either the head or body fallback boundary.
1020    pub(crate) component_asset_styles_emitted: bool,
1021    /// Tracks whether the `body_end` hook has already fired in this
1022    /// render. Defends against malformed protocols emitting the
1023    /// signal twice — without this, hydration `<script>` blocks and
1024    /// host-supplied `body_inject` would be duplicated.
1025    pub(crate) body_end_emitted: bool,
1026    /// Immutable authored route patterns compiled when [`Protocol`] is loaded.
1027    pub(crate) route_index: &'protocol CompiledRouteIndex,
1028    /// Counter for `data-ri` attributes on matched route elements.
1029    /// Incremented each time a matched route is rendered, allowing O(1) element
1030    /// binding on the client side instead of DOM-walking.
1031    pub(crate) route_chain_index: usize,
1032    /// Matched route metadata computed at most once when head styles or body
1033    /// bootstrap first need it, then shared by streaming checkpoints.
1034    pub(crate) route_chain: Option<Vec<crate::route_handler::RouteChainEntry>>,
1035    /// Whether each matched route closure installs into the Document CSS tree.
1036    ///
1037    /// Kept parallel to `route_chain`; the route graph computes both in one walk.
1038    pub(crate) route_document_style_targets: Vec<bool>,
1039    /// Request-reachable components in deterministic first-discovery order.
1040    ///
1041    /// Resolved once at `head_end` for Shadow Link preloads, then consumed at
1042    /// `body_end` for hydration metadata so the fragment graph is not walked
1043    /// twice per request.
1044    pub(crate) reachable_components: Option<Vec<String>>,
1045    /// Present only for the opt-in progressive streaming render path.
1046    pub(crate) streaming: Option<&'output mut StreamingRenderState<'protocol>>,
1047    /// Reusable JSON serialization scratch buffer, owned by the render context.
1048    /// The bootstrap/field/template helpers borrow it so each render reuses one
1049    /// buffer across every serialized field and every streaming checkpoint
1050    /// instead of allocating per value. It grows lazily on first serialization
1051    /// and is dropped with the context at request end — no per-thread
1052    /// high-water buffer is retained between requests.
1053    pub(crate) json_scratch: Vec<u8>,
1054    /// Small request-local pool of cleared scope maps reused across sibling
1055    /// component roots. `process_component` recycles each finished component's
1056    /// local/attr map here instead of dropping it, so a sibling reuses the
1057    /// bucket capacity rather than reallocating a fresh `HashMap`. Bounded
1058    /// ([`SCOPE_POOL_CAP`]) and dropped with the context at request end.
1059    pub(crate) scope_pool: Vec<ScopeMap>,
1060    /// Resources delivered into the Document CSS tree. The empty set does not
1061    /// allocate, and streaming retains it across checkpoints.
1062    pub(crate) document_style_resources: HashSet<String>,
1063    /// Active Shadow roots and their route-activated resource indexes. The
1064    /// route vector allocates only when a matched Light route contributes CSS.
1065    pub(crate) shadow_style_roots: Vec<ShadowStyleRoot>,
1066    /// Capacity-preserving pool for borrowed component scope maps.
1067    borrowed_scope_pool: Vec<BorrowedScope<'protocol, 'state>>,
1068}
1069
1070/// Compiler-owned signal namespace. The leading `}}}` cannot be produced by
1071/// authored double- or triple-brace expressions because it closes the binding.
1072pub(crate) const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
1073
1074pub(crate) fn structural_signal_value(
1075    signal: &webui_protocol::WebUIFragmentSignal,
1076) -> Option<&str> {
1077    if !signal.raw {
1078        return None;
1079    }
1080    signal.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
1081}
1082
1083pub(crate) fn write_interaction_marker(
1084    tag: &str,
1085    context: &mut WebUIProcessContext<'_, '_, '_>,
1086) -> Result<()> {
1087    if matches!(
1088        context
1089            .protocol
1090            .components
1091            .get(tag)
1092            .and_then(|component| ComponentWorkPolicy::try_from(component.work_policy).ok()),
1093        Some(ComponentWorkPolicy::Interaction | ComponentWorkPolicy::LazyRenderInteraction)
1094    ) {
1095        context.writer.write(" data-webui-interaction")?;
1096    }
1097    Ok(())
1098}
1099
1100/// Find the end of a leading doctype without treating quoted `>` bytes in
1101/// legacy PUBLIC or SYSTEM identifiers as the declaration close.
1102fn doctype_prefix_end(raw: &str) -> Option<usize> {
1103    const PREFIX: &[u8] = b"<!doctype";
1104
1105    let bytes = raw.as_bytes();
1106    let mut cursor = usize::from(bytes.starts_with(b"\xEF\xBB\xBF")) * 3;
1107    while bytes
1108        .get(cursor)
1109        .is_some_and(|byte| byte.is_ascii_whitespace())
1110    {
1111        cursor += 1;
1112    }
1113
1114    let prefix_end = cursor.checked_add(PREFIX.len())?;
1115    if !bytes.get(cursor..prefix_end)?.eq_ignore_ascii_case(PREFIX) {
1116        return None;
1117    }
1118    let separator = *bytes.get(prefix_end)?;
1119    if separator != b'>' && !separator.is_ascii_whitespace() {
1120        return None;
1121    }
1122
1123    let mut quote = None;
1124    for (offset, byte) in bytes[prefix_end..].iter().copied().enumerate() {
1125        match quote {
1126            Some(active) if byte == active => quote = None,
1127            Some(_) => {}
1128            None if matches!(byte, b'\'' | b'"') => quote = Some(byte),
1129            None if byte == b'>' => return Some(prefix_end + offset + 1),
1130            None => {}
1131        }
1132    }
1133    None
1134}
1135
1136/// Maximum scope maps retained in the request-local pool. Small: sibling
1137/// component roots rarely nest deeply, so the cap keeps retained capacity bounded.
1138const SCOPE_POOL_CAP: usize = 8;
1139/// Borrowed component scopes typically contain only a handful of attributes.
1140const INLINE_SCOPE_SLOTS: usize = 4;
1141/// Fragment lists memoized inline per render. Sized to cover the distinct lists
1142/// a typical app touches in one render while keeping the per-render zeroing cost
1143/// negligible; larger protocols fall back to the protocol map for the overflow
1144/// slots instead of eagerly materializing every list.
1145/// Inline memo slots for fragment lists.
1146///
1147/// Deliberately small: the array is zeroed on every render, so a shallow render
1148/// touching two fragments should not pay to clear slots it will never read.
1149/// Deeper renders spill into a lazily allocated slice, which keeps them fully
1150/// memoized without charging that cost to the common shallow case.
1151const INLINE_RENDER_FRAGMENT_LISTS: usize = 8;
1152/// Style-closure units deduplicated inline before falling back to hashing.
1153const INLINE_STYLE_UNITS: usize = 8;
1154const COMPONENT_ASSET_MANIFEST_ID: &str = "webui-component-assets";
1155
1156/// Delivery units already emitted by one style-closure walk.
1157///
1158/// Every Shadow root walks its closure on every render and most closures hold
1159/// only a few units, so a linear scan over an inline array replaces a hash set
1160/// allocated per walk. Larger closures continue in a hash set.
1161struct EmittedStyleUnits<'a> {
1162    inline: [&'a str; INLINE_STYLE_UNITS],
1163    inline_len: usize,
1164    overflow: HashSet<&'a str>,
1165}
1166
1167impl<'a> EmittedStyleUnits<'a> {
1168    fn new() -> Self {
1169        Self {
1170            inline: [""; INLINE_STYLE_UNITS],
1171            inline_len: 0,
1172            overflow: HashSet::new(),
1173        }
1174    }
1175
1176    /// Record `name`, returning `false` when it was already emitted.
1177    fn insert(&mut self, name: &'a str) -> bool {
1178        if self.inline[..self.inline_len].contains(&name) {
1179            return false;
1180        }
1181        if self.inline_len < INLINE_STYLE_UNITS {
1182            self.inline[self.inline_len] = name;
1183            self.inline_len += 1;
1184            return true;
1185        }
1186        self.overflow.insert(name)
1187    }
1188}
1189
1190struct ComponentAssetStyleManifest<'a>(&'a [ComponentAssetStylePreload]);
1191
1192impl Serialize for ComponentAssetStyleManifest<'_> {
1193    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1194    where
1195        S: serde::Serializer,
1196    {
1197        let mut map = serializer.serialize_map(Some(self.0.len()))?;
1198        for entry in self.0 {
1199            map.serialize_entry(&entry.root, &entry.style_hrefs)?;
1200        }
1201        map.end()
1202    }
1203}
1204
1205fn serialize_component_asset_style_manifest(
1206    preloads: &[ComponentAssetStylePreload],
1207) -> std::result::Result<String, serde_json::Error> {
1208    if preloads.is_empty() {
1209        return Ok(String::new());
1210    }
1211    let json = serde_json::to_string(&ComponentAssetStyleManifest(preloads))?;
1212    if json.contains("</") {
1213        Ok(json.replace("</", "<\\/"))
1214    } else {
1215        Ok(json)
1216    }
1217}
1218
1219fn serialize_component_asset_style_links(preloads: &[ComponentAssetStylePreload]) -> String {
1220    let href_count = preloads.iter().map(|entry| entry.style_hrefs.len()).sum();
1221    let mut seen = HashSet::with_capacity(href_count);
1222    let mut links = String::with_capacity(href_count.saturating_mul(48));
1223    for entry in preloads {
1224        for href in &entry.style_hrefs {
1225            if !seen.insert(href.as_str()) {
1226                continue;
1227            }
1228            links.push_str("<link rel=\"stylesheet\" href=\"");
1229            push_escaped_html_attribute(&mut links, href);
1230            links.push_str("\">");
1231        }
1232    }
1233    links
1234}
1235
1236fn push_escaped_html_attribute(output: &mut String, value: &str) {
1237    let mut start = 0;
1238    for (index, ch) in value.char_indices() {
1239        let escaped = match ch {
1240            '&' => Some("&amp;"),
1241            '"' => Some("&quot;"),
1242            '<' => Some("&lt;"),
1243            '>' => Some("&gt;"),
1244            _ => None,
1245        };
1246        let Some(entity) = escaped else {
1247            continue;
1248        };
1249        if start < index {
1250            output.push_str(&value[start..index]);
1251        }
1252        output.push_str(entity);
1253        start = index + ch.len_utf8();
1254    }
1255    if start < value.len() {
1256        output.push_str(&value[start..]);
1257    }
1258}
1259
1260/// Drop an owned component prop that a state-backed value now shadows.
1261///
1262/// `HashMap::remove` hashes its key even when the map is empty, which is the
1263/// case for components whose props are all state-backed.
1264fn remove_owned_component_attr(attrs: &mut ScopeMap, name: &str) {
1265    if !attrs.is_empty() {
1266        attrs.remove(name);
1267    }
1268}
1269
1270/// Take a cleared scope map from the pool, or a fresh empty one when the pool is
1271/// empty. A fresh `HashMap` does not allocate until its first insert.
1272fn take_scope_map(pool: &mut Vec<ScopeMap>) -> ScopeMap {
1273    pool.pop().unwrap_or_default()
1274}
1275
1276/// Return a spent scope map to the pool, clearing it but retaining its bucket
1277/// capacity for a sibling root to reuse. Drops the map once the pool is full so
1278/// retained memory stays bounded.
1279fn recycle_scope_map(pool: &mut Vec<ScopeMap>, mut map: ScopeMap) {
1280    if pool.len() < SCOPE_POOL_CAP {
1281        map.clear();
1282        pool.push(map);
1283    }
1284}
1285
1286fn take_borrowed_scope<'protocol, 'state>(
1287    pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1288) -> BorrowedScope<'protocol, 'state> {
1289    pool.pop().unwrap_or_default()
1290}
1291
1292fn recycle_borrowed_scope<'protocol, 'state>(
1293    pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1294    mut scope: BorrowedScope<'protocol, 'state>,
1295) {
1296    if pool.len() < SCOPE_POOL_CAP {
1297        scope.clear();
1298        pool.push(scope);
1299    }
1300}
1301
1302pub(crate) enum WebUiBootstrapState<'a> {
1303    Complete {
1304        value: &'a Value,
1305        selection: StateSelection<'a>,
1306    },
1307    Reference {
1308        record_sequence: usize,
1309        value: &'a Value,
1310        delta: Option<StateSelection<'a>>,
1311    },
1312}
1313
1314pub(crate) struct WebUiBootstrap<'a, ComponentStyles: Serialize + ?Sized = Value> {
1315    pub(crate) declaration_id: Option<u32>,
1316    pub(crate) enclosing_span_instance_id: Option<u32>,
1317    pub(crate) state: WebUiBootstrapState<'a>,
1318    pub(crate) chain: &'a [Value],
1319    pub(crate) inventory: &'a str,
1320    pub(crate) nonce: Option<&'a str>,
1321    pub(crate) css_hrefs: &'a [&'a str],
1322    pub(crate) style_specs: &'a [&'a str],
1323    pub(crate) component_styles: &'a ComponentStyles,
1324    pub(crate) templates: &'a [WebUiTemplatePayload<'a>],
1325}
1326
1327/// Get the component attribute name, stripping `:` prefix and converting to camelCase.
1328///
1329/// Uses `webui_protocol::attrs::attribute_to_camel` which handles irregular
1330/// attributes (multi-word ARIA and global HTML attributes like `readonly`,
1331/// `tabindex`) via the shared lookup table.
1332///
1333/// Renders read prepared names out of [`RenderFragmentIndex`] instead of calling
1334/// this, so it remains only as the single-name form of
1335/// [`push_component_attr_name`] for tests.
1336#[cfg(test)]
1337fn component_attr_name(name: &str) -> String {
1338    let mut buffer = String::new();
1339    push_component_attr_name(&mut buffer, name);
1340    buffer
1341}
1342
1343/// Append the canonical camelCase component prop name for `name` to `buffer`.
1344///
1345/// Strips a leading `:` and converts via `webui_protocol::attrs::attribute_to_camel`,
1346/// which handles irregular attributes (multi-word ARIA and global HTML attributes
1347/// like `readonly`, `tabindex`) through the shared lookup table.
1348///
1349/// Used while preparing the render fragment index so a protocol with thousands
1350/// of attribute fragments grows one arena instead of allocating per name.
1351fn push_component_attr_name(buffer: &mut String, name: &str) {
1352    let stripped = name.strip_prefix(':').unwrap_or(name);
1353    // Same conversion as `webui_protocol::attrs::attribute_to_camel`, written
1354    // straight into the shared arena. That function returns an owned `String`,
1355    // so calling it here would allocate and drop one buffer per attribute while
1356    // preparing a protocol.
1357    if let Some(property) = webui_protocol::attrs::attribute_to_property(stripped) {
1358        buffer.push_str(property);
1359        return;
1360    }
1361    buffer.reserve(stripped.len());
1362    let mut capitalize_next = false;
1363    for ch in stripped.chars() {
1364        if ch == '-' {
1365            capitalize_next = true;
1366        } else if capitalize_next {
1367            buffer.extend(ch.to_uppercase());
1368            capitalize_next = false;
1369        } else {
1370            buffer.push(ch);
1371        }
1372    }
1373}
1374
1375#[cold]
1376#[inline(never)]
1377fn missing_component_attr_name_error() -> HandlerError {
1378    HandlerError::Invariant("prepared component attribute name is missing".to_string())
1379}
1380
1381/// Fixed-capacity [`fmt::Write`] sink used to render JSON scalars on the stack.
1382///
1383/// `serde_json::Number` is backed by `i64`, `u64`, or `f64` (the crate is built
1384/// without `arbitrary_precision`), so its longest rendering is the 24-byte
1385/// `ryu` form of an extreme `f64`. The buffer is oversized well past that, and
1386/// an overflow is reported rather than truncated so callers fall back to the
1387/// allocating path instead of emitting a malformed value.
1388struct ScalarBuffer {
1389    buf: [u8; 48],
1390    len: usize,
1391}
1392
1393impl ScalarBuffer {
1394    #[inline]
1395    const fn new() -> Self {
1396        Self {
1397            buf: [0; 48],
1398            len: 0,
1399        }
1400    }
1401
1402    #[inline]
1403    fn as_str(&self) -> Option<&str> {
1404        std::str::from_utf8(&self.buf[..self.len]).ok()
1405    }
1406}
1407
1408impl fmt::Write for ScalarBuffer {
1409    #[inline]
1410    fn write_str(&mut self, s: &str) -> fmt::Result {
1411        let bytes = s.as_bytes();
1412        let end = self.len.checked_add(bytes.len()).ok_or(fmt::Error)?;
1413        let slot = self.buf.get_mut(self.len..end).ok_or(fmt::Error)?;
1414        slot.copy_from_slice(bytes);
1415        self.len = end;
1416        Ok(())
1417    }
1418}
1419
1420/// Render a JSON scalar that can never contain HTML-significant bytes.
1421///
1422/// Numbers, booleans, and `null` render identically escaped and unescaped, so
1423/// this bypasses both the `Value::to_string` allocation and the `encode_safe`
1424/// scan that the generic display path performs. Returns `None` when `value` is
1425/// a string, array, or object, leaving those to the caller's fallback.
1426fn format_plain_json_scalar<'buf>(
1427    buffer: &'buf mut ScalarBuffer,
1428    value: &Value,
1429) -> Option<&'buf str> {
1430    match value {
1431        Value::Bool(true) => Some("true"),
1432        Value::Bool(false) => Some("false"),
1433        Value::Null => Some("null"),
1434        Value::Number(number) => {
1435            // `Display for Number` is the same serializer `Value::to_string`
1436            // drives, so the bytes are identical to the allocating path.
1437            write!(buffer, "{number}").ok()?;
1438            buffer.as_str()
1439        }
1440        _ => None,
1441    }
1442}
1443
1444/// Write a JSON scalar straight to the sink, skipping escaping and allocation.
1445///
1446/// Returns `false` when `value` needs the caller's allocating fallback.
1447fn write_plain_json_scalar(writer: &mut dyn ResponseWriter, value: &Value) -> Result<bool> {
1448    let mut buffer = ScalarBuffer::new();
1449    match format_plain_json_scalar(&mut buffer, value) {
1450        Some(rendered) => {
1451            writer.write(rendered)?;
1452            Ok(true)
1453        }
1454        None => Ok(false),
1455    }
1456}
1457
1458/// Write a usize as decimal digits directly to the writer, avoiding `format!` allocation.
1459pub(crate) fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
1460    if n == 0 {
1461        return writer.write("0");
1462    }
1463    // Max digits for a 64-bit usize is 20.
1464    let mut buf = [0u8; 20];
1465    let mut pos = buf.len();
1466    while n > 0 {
1467        pos -= 1;
1468        // n % 10 is always in 0..=9, fits in u8 without truncation.
1469        #[allow(clippy::cast_possible_truncation)]
1470        let digit = (n % 10) as u8;
1471        buf[pos] = b'0' + digit;
1472        n /= 10;
1473    }
1474    // Digits are always valid ASCII/UTF-8.
1475    match std::str::from_utf8(&buf[pos..]) {
1476        Ok(s) => writer.write(s),
1477        Err(_) => writer.write("0"),
1478    }
1479}
1480
1481pub(crate) fn write_script_safe_json<T>(
1482    writer: &mut dyn ResponseWriter,
1483    scratch: &mut Vec<u8>,
1484    value: &T,
1485) -> Result<()>
1486where
1487    T: Serialize + ?Sized,
1488{
1489    // Serialize into the caller's request-local `scratch`. The streaming path
1490    // emits one bootstrap envelope per committed boundary and the ordinary
1491    // body_end bootstrap serializes several fields, so reusing one buffer across
1492    // the render avoids a fresh allocation per value. The buffer grows lazily on
1493    // first use (no allocation until serialization needs it) and is dropped with
1494    // the render context — capacity is reused within a request but never
1495    // retained across requests.
1496    scratch.clear();
1497    serde_json::to_writer(&mut *scratch, value)
1498        .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
1499    let json = std::str::from_utf8(scratch)
1500        .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
1501    write_script_safe_json_str(writer, json)
1502}
1503
1504/// `</` finder shared by every call, so short template strings don't pay to
1505/// build one per call.
1506static END_TAG_OPENER: LazyLock<memchr::memmem::Finder<'static>> =
1507    LazyLock::new(|| memchr::memmem::Finder::new(b"</"));
1508
1509fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
1510    // Most serialized state has no `<`, so a single-byte scan settles it. From
1511    // the first `<` on, search for the `</` pair itself so markup-heavy payloads
1512    // don't stop at every opening or self-closing tag.
1513    let bytes = json.as_bytes();
1514    let mut start = 0;
1515    if let Some(first) = memchr::memchr(b'<', bytes) {
1516        for offset in END_TAG_OPENER.find_iter(&bytes[first..]) {
1517            let index = first + offset;
1518            if index > start {
1519                writer.write(&json[start..index])?;
1520            }
1521            writer.write("<\\/")?;
1522            start = index + 2;
1523        }
1524    }
1525    if start < json.len() {
1526        writer.write(&json[start..])?;
1527    }
1528    Ok(())
1529}
1530
1531fn write_json_field_name(
1532    writer: &mut dyn ResponseWriter,
1533    wrote_field: &mut bool,
1534    name: &str,
1535) -> Result<()> {
1536    if *wrote_field {
1537        writer.write(",")?;
1538    }
1539    *wrote_field = true;
1540    writer.write("\"")?;
1541    writer.write(name)?;
1542    writer.write("\":")
1543}
1544
1545fn write_json_field<T>(
1546    writer: &mut dyn ResponseWriter,
1547    scratch: &mut Vec<u8>,
1548    wrote_field: &mut bool,
1549    name: &str,
1550    value: &T,
1551) -> Result<()>
1552where
1553    T: Serialize + ?Sized,
1554{
1555    write_json_field_name(writer, wrote_field, name)?;
1556    write_script_safe_json(writer, scratch, value)
1557}
1558
1559/// Serialize wrapper that projects an SSR state object down to only the
1560/// keys present in the build-time hydration allowlist.
1561///
1562/// This is the runtime half of the projected-hydration design: instead of
1563/// serializing the entire application state (potentially megabytes) on every
1564/// full-HTML render, only the fields a component actually hydrates are
1565/// emitted. The request allowlist conservatively includes every reachable
1566/// component's hydration keys so no field a component needs is dropped.
1567///
1568/// Projection is a payload boundary, not a secrecy boundary. Any key selected
1569/// by compiled client metadata is browser-facing, so hosts must never place
1570/// secrets in browser render state.
1571///
1572/// `keys` MUST be sorted and deduplicated. Projection iterates whichever side
1573/// is smaller: hydration keys with direct map lookup for wide states, or state
1574/// entries with binary-search membership for compact states. Non-object states
1575/// carry nothing hydratable and serialize as an empty object.
1576struct ProjectedState<'a> {
1577    value: &'a Value,
1578    keys: KeyView<'a>,
1579}
1580
1581impl Serialize for ProjectedState<'_> {
1582    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1583    where
1584        S: serde::Serializer,
1585    {
1586        let Value::Object(map) = self.value else {
1587            return serializer.serialize_map(Some(0))?.end();
1588        };
1589
1590        let mut out = serializer.serialize_map(None)?;
1591        if self.keys.len() < map.len() {
1592            let mut previous = None;
1593            for key in self.keys.iter() {
1594                if key == STATE_INJECT_KEY {
1595                    continue;
1596                }
1597                if previous == Some(key) {
1598                    continue;
1599                }
1600                previous = Some(key);
1601                if let Some(value) = map.get(key) {
1602                    out.serialize_entry(key, value)?;
1603                }
1604            }
1605        } else {
1606            for (key, value) in map {
1607                if key == STATE_INJECT_KEY {
1608                    continue;
1609                }
1610                if self.keys.contains(key.as_str()) {
1611                    out.serialize_entry(key, value)?;
1612                }
1613            }
1614        }
1615        out.end()
1616    }
1617}
1618
1619/// Write the SSR `state` into the bootstrap block according to the protocol's
1620/// build-time selection and escape it for safe embedding in a `<script>`.
1621///
1622/// [`ProjectedState`] serializes only the allowlisted keys, so for the typical
1623/// payload — a large state with a small hydratable surface — serde ever only
1624/// touches the projected subset. Serialization reuses the proven
1625/// [`write_script_safe_json`] path (serde's fast `Vec<u8>` target plus a single
1626/// SIMD-accelerated `</` escape pass), which matches the pre-projection cost
1627/// when every key is hydratable and collapses to a few bytes when it is not.
1628/// Buffering the projected bytes and escaping once is measurably faster than
1629/// streaming through a per-token `io::Write` adapter, and the projected buffer
1630/// is tiny in the common case.
1631/// Serialize a state object with the reserved [`STATE_INJECT_KEY`] entry
1632/// omitted.
1633///
1634/// The reserved key carries host-supplied boundary HTML, not application
1635/// state, so it must never reach the client hydration payload — shipping it
1636/// would both duplicate the markup as a JSON string and expose the host's
1637/// inject channel to client code. Filtering happens during serialization so
1638/// the state tree is never cloned.
1639struct StateWithoutReservedKey<'a> {
1640    value: &'a Value,
1641}
1642
1643impl Serialize for StateWithoutReservedKey<'_> {
1644    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1645    where
1646        S: serde::Serializer,
1647    {
1648        let Value::Object(map) = self.value else {
1649            return self.value.serialize(serializer);
1650        };
1651        let mut out = serializer.serialize_map(Some(map.len().saturating_sub(1)))?;
1652        for (key, value) in map {
1653            if key == STATE_INJECT_KEY {
1654                continue;
1655            }
1656            out.serialize_entry(key, value)?;
1657        }
1658        out.end()
1659    }
1660}
1661
1662/// Write the complete state, stripping the reserved inject key when present.
1663///
1664/// The membership test is a single map lookup and the common case — no
1665/// reserved key — takes the original zero-overhead path unchanged.
1666fn write_full_state(
1667    writer: &mut dyn ResponseWriter,
1668    scratch: &mut Vec<u8>,
1669    state: &Value,
1670) -> Result<()> {
1671    if matches!(state, Value::Object(map) if map.contains_key(STATE_INJECT_KEY)) {
1672        return write_script_safe_json(writer, scratch, &StateWithoutReservedKey { value: state });
1673    }
1674    write_script_safe_json(writer, scratch, state)
1675}
1676
1677pub(crate) fn write_selected_state(
1678    writer: &mut dyn ResponseWriter,
1679    scratch: &mut Vec<u8>,
1680    state: &Value,
1681    selection: &StateSelection<'_>,
1682) -> Result<()> {
1683    let keys = match selection {
1684        StateSelection::Full => return write_full_state(writer, scratch, state),
1685        StateSelection::Keys(keys) => KeyView::Borrowed(keys.as_slice()),
1686        StateSelection::KeyIds(selection) => KeyView::Ids(*selection),
1687        StateSelection::FullExceptKeyIds(selection) => {
1688            return write_script_safe_json(
1689                writer,
1690                scratch,
1691                &StateWithoutSelectedKeys {
1692                    value: state,
1693                    keys: KeyView::Ids(*selection),
1694                },
1695            );
1696        }
1697    };
1698    if keys.is_empty() {
1699        return writer.write("{}");
1700    }
1701
1702    // Projection membership may use binary search, so a mis-sorted key set
1703    // would silently drop hydration keys. The key allowlist is produced sorted +
1704    // deduped at build time; this guard makes hand-built protocols that violate
1705    // the invariant fail loudly in tests at zero release cost.
1706    debug_assert!(
1707        keys.iter()
1708            .zip(keys.iter().skip(1))
1709            .all(|(left, right)| left <= right),
1710        "hydration keys must be sorted for binary-search projection"
1711    );
1712    if let Value::Object(map) = state {
1713        let selects_entire_map =
1714            keys.len() == map.len() && keys.iter().eq(map.keys().map(String::as_str));
1715        if selects_entire_map {
1716            return write_full_state(writer, scratch, state);
1717        }
1718    }
1719    write_script_safe_json(writer, scratch, &ProjectedState { value: state, keys })
1720}
1721
1722// Covers the common route surface without trusting protocol-derived counts for
1723// an eager allocation; larger key sets grow only as actual keys are visited.
1724pub(crate) const INITIAL_KEY_CAPACITY: usize = 16;
1725
1726/// Request-scoped state selection derived from reachable component metadata.
1727pub(crate) enum StateSelection<'a> {
1728    /// Preserve the complete state value.
1729    Full,
1730    /// Project an object to a sorted, deduplicated key allowlist.
1731    Keys(Vec<&'a str>),
1732    /// Project using the streaming continuation's interned hydration key IDs.
1733    ///
1734    /// IDs are assigned in lexicographic order, so a sorted ID slice is a
1735    /// sorted key slice and the projection needs no borrowed-string buffer at
1736    /// all — the streaming record keeps its scratch as plain integers that
1737    /// survive every semantic step.
1738    KeyIds(HydrationKeySelection<'a>),
1739    /// Preserve full state except keys inherited from a referenced range state.
1740    FullExceptKeyIds(HydrationKeySelection<'a>),
1741}
1742
1743/// A projection expressed as interned hydration key IDs.
1744#[derive(Clone, Copy)]
1745pub(crate) struct HydrationKeySelection<'a> {
1746    pub(crate) ids: &'a [u32],
1747    pub(crate) index: &'a ComponentReachabilityIndex,
1748}
1749
1750impl<'a> HydrationKeySelection<'a> {
1751    fn key(self, position: usize) -> Option<&'a str> {
1752        let id = self.ids.get(position).copied()?;
1753        self.index.hydration_key(id)
1754    }
1755}
1756
1757/// A sorted, deduplicated key allowlist in whichever form its producer holds.
1758#[derive(Clone, Copy)]
1759enum KeyView<'a> {
1760    Borrowed(&'a [&'a str]),
1761    Ids(HydrationKeySelection<'a>),
1762}
1763
1764impl<'a> KeyView<'a> {
1765    fn len(self) -> usize {
1766        match self {
1767            Self::Borrowed(keys) => keys.len(),
1768            Self::Ids(selection) => selection.ids.len(),
1769        }
1770    }
1771
1772    fn is_empty(self) -> bool {
1773        self.len() == 0
1774    }
1775
1776    fn get(self, position: usize) -> Option<&'a str> {
1777        match self {
1778            Self::Borrowed(keys) => keys.get(position).copied(),
1779            Self::Ids(selection) => selection.key(position),
1780        }
1781    }
1782
1783    fn iter(self) -> impl Iterator<Item = &'a str> {
1784        (0..self.len()).map_while(move |position| self.get(position))
1785    }
1786
1787    /// Sorted-membership probe used when the state object is smaller than the
1788    /// allowlist.
1789    fn contains(self, key: &str) -> bool {
1790        match self {
1791            Self::Borrowed(keys) => keys
1792                .binary_search_by(|candidate| str::cmp(candidate, key))
1793                .is_ok(),
1794            Self::Ids(selection) => selection
1795                .ids
1796                .binary_search_by(|id| {
1797                    selection
1798                        .index
1799                        .hydration_key(*id)
1800                        .map_or(std::cmp::Ordering::Less, |candidate| {
1801                            str::cmp(candidate, key)
1802                        })
1803                })
1804                .is_ok(),
1805        }
1806    }
1807}
1808
1809#[derive(Clone, Copy)]
1810enum ComponentStateSurface {
1811    Hydration,
1812    Navigation,
1813}
1814
1815/// Select initial state for the components reachable on this request path.
1816///
1817/// Non-WebUI protocols preserve full state without walking component surfaces.
1818/// WebUI protocols project exact surfaces, while any unknown surface restores
1819/// the full state for correctness.
1820pub(crate) fn collect_hydration_state<'a, 'b>(
1821    protocol: &'a WebUIProtocol,
1822    components: impl IntoIterator<Item = &'b str>,
1823) -> StateSelection<'a> {
1824    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1825        return StateSelection::Full;
1826    }
1827    collect_component_state(protocol, components, ComponentStateSurface::Hydration)
1828}
1829
1830/// Fill a reusable hydration key-ID allowlist for a streaming record.
1831///
1832/// Components arrive as inventory indexes, so the projection never resolves a
1833/// component name, never hashes it against the compiled component map, and
1834/// never sorts strings: the interned runs concatenate and the integer sort
1835/// leaves the IDs in lexicographic key order. Returns `true` when correctness
1836/// requires sending full state instead.
1837pub(crate) fn collect_hydration_key_ids_into(
1838    protocol: &WebUIProtocol,
1839    index: &ComponentReachabilityIndex,
1840    components: impl IntoIterator<Item = u32>,
1841    ids: &mut Vec<u32>,
1842) -> bool {
1843    ids.clear();
1844    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1845        return true;
1846    }
1847    for component in components {
1848        if index.extend_hydration_keys(component, ids) {
1849            ids.clear();
1850            return true;
1851        }
1852    }
1853    ids.sort_unstable();
1854    ids.dedup();
1855    false
1856}
1857
1858/// Select state for client-created components reachable during navigation.
1859pub(crate) fn collect_navigation_state<'a, 'b>(
1860    protocol: &'a WebUIProtocol,
1861    components: impl IntoIterator<Item = &'b str>,
1862) -> StateSelection<'a> {
1863    collect_component_state(protocol, components, ComponentStateSurface::Navigation)
1864}
1865
1866fn collect_component_state<'a, 'b>(
1867    protocol: &'a WebUIProtocol,
1868    components: impl IntoIterator<Item = &'b str>,
1869    surface: ComponentStateSurface,
1870) -> StateSelection<'a> {
1871    let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
1872    if collect_component_state_into(protocol, components, surface, &mut keys) {
1873        StateSelection::Full
1874    } else {
1875        StateSelection::Keys(keys)
1876    }
1877}
1878
1879/// Fill a reusable state-key allowlist. Returns `true` when correctness
1880/// requires sending full state instead of the collected keys.
1881fn collect_component_state_into<'a, 'b>(
1882    protocol: &'a WebUIProtocol,
1883    components: impl IntoIterator<Item = &'b str>,
1884    surface: ComponentStateSurface,
1885    keys: &mut Vec<&'a str>,
1886) -> bool {
1887    keys.clear();
1888    for name in components {
1889        let Some(component) = protocol.components.get(name) else {
1890            return true;
1891        };
1892        let (mode, component_keys) = match surface {
1893            ComponentStateSurface::Hydration => {
1894                (component.hydration_mode, &component.hydration_keys)
1895            }
1896            ComponentStateSurface::Navigation => (
1897                match component.navigation_mode {
1898                    Some(mode) => mode,
1899                    None if !component.navigation_keys.is_empty() => {
1900                        StateProjectionMode::Keys as i32
1901                    }
1902                    None => return true,
1903                },
1904                &component.navigation_keys,
1905            ),
1906        };
1907        if mode == StateProjectionMode::All as i32 {
1908            return true;
1909        }
1910        if mode == StateProjectionMode::Keys as i32
1911            || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
1912        {
1913            keys.extend(component_keys.iter().map(String::as_str));
1914        } else if mode != StateProjectionMode::None as i32 {
1915            return true;
1916        }
1917    }
1918    keys.sort_unstable();
1919    keys.dedup();
1920    false
1921}
1922
1923pub(crate) fn write_webui_bootstrap<ComponentStyles: Serialize + ?Sized>(
1924    writer: &mut dyn ResponseWriter,
1925    scratch: &mut Vec<u8>,
1926    bootstrap: WebUiBootstrap<'_, ComponentStyles>,
1927) -> Result<()> {
1928    let mut wrote_field = false;
1929
1930    writer.write("{")?;
1931    if let Some(declaration_id) = bootstrap.declaration_id {
1932        write_json_field(
1933            writer,
1934            scratch,
1935            &mut wrote_field,
1936            "declarationId",
1937            &declaration_id,
1938        )?;
1939    }
1940    if let Some(span_id) = bootstrap.enclosing_span_instance_id {
1941        write_json_field(
1942            writer,
1943            scratch,
1944            &mut wrote_field,
1945            "enclosingSpanInstanceId",
1946            &span_id,
1947        )?;
1948    }
1949    if !bootstrap.chain.is_empty() {
1950        write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?;
1951    }
1952    // Definitions must be visible before template metadata can cause the
1953    // runtime to create a component root.
1954    write_json_field(
1955        writer,
1956        scratch,
1957        &mut wrote_field,
1958        "componentStyles",
1959        bootstrap.component_styles,
1960    )?;
1961    if !bootstrap.css_hrefs.is_empty() {
1962        write_json_field(
1963            writer,
1964            scratch,
1965            &mut wrote_field,
1966            "css",
1967            bootstrap.css_hrefs,
1968        )?;
1969    }
1970    write_json_field(
1971        writer,
1972        scratch,
1973        &mut wrote_field,
1974        "inventory",
1975        bootstrap.inventory,
1976    )?;
1977    if let Some(nonce) = bootstrap.nonce {
1978        write_json_field(writer, scratch, &mut wrote_field, "nonce", nonce)?;
1979    }
1980    match bootstrap.state {
1981        WebUiBootstrapState::Complete { value, selection } => {
1982            write_json_field_name(writer, &mut wrote_field, "state")?;
1983            write_selected_state(writer, scratch, value, &selection)?;
1984        }
1985        WebUiBootstrapState::Reference {
1986            record_sequence,
1987            value,
1988            delta,
1989        } => {
1990            write_json_field(
1991                writer,
1992                scratch,
1993                &mut wrote_field,
1994                "stateRef",
1995                &record_sequence,
1996            )?;
1997            if let Some(delta) = delta {
1998                write_json_field_name(writer, &mut wrote_field, "stateDelta")?;
1999                write_selected_state(writer, scratch, value, &delta)?;
2000            }
2001        }
2002    }
2003    if !bootstrap.style_specs.is_empty() {
2004        write_json_field(
2005            writer,
2006            scratch,
2007            &mut wrote_field,
2008            "styles",
2009            bootstrap.style_specs,
2010        )?;
2011    }
2012    if bootstrap
2013        .templates
2014        .iter()
2015        .any(|template| !template.template_json.is_empty())
2016    {
2017        write_json_field_name(writer, &mut wrote_field, "templates")?;
2018        write_webui_template_json_map(writer, scratch, bootstrap.templates)?;
2019    }
2020    writer.write("}")
2021}
2022
2023fn write_webui_data_block(
2024    writer: &mut dyn ResponseWriter,
2025    scratch: &mut Vec<u8>,
2026    bootstrap: WebUiBootstrap<'_>,
2027) -> Result<()> {
2028    writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
2029    if let Some(nonce) = bootstrap.nonce {
2030        writer.write(" nonce=\"")?;
2031        writer.write(nonce)?;
2032        writer.write("\"")?;
2033    }
2034
2035    writer.write(">")?;
2036    write_webui_bootstrap(writer, scratch, bootstrap)?;
2037    writer.write("</script>\n")
2038}
2039
2040fn write_component_asset_style_manifest(
2041    writer: &mut dyn ResponseWriter,
2042    manifest: &str,
2043    nonce: Option<&str>,
2044) -> Result<()> {
2045    if manifest.is_empty() {
2046        return Ok(());
2047    }
2048    writer.write("<script type=\"application/json\" id=\"")?;
2049    writer.write(COMPONENT_ASSET_MANIFEST_ID)?;
2050    writer.write("\"")?;
2051    if let Some(nonce) = nonce {
2052        writer.write(" nonce=\"")?;
2053        writer.write(&crate::html_encode::encode_safe(nonce))?;
2054        writer.write("\"")?;
2055    }
2056    writer.write(">")?;
2057    writer.write(manifest)?;
2058    writer.write("</script>")
2059}
2060
2061fn write_component_asset_styles(context: &mut WebUIProcessContext<'_, '_, '_>) -> Result<()> {
2062    if context.component_asset_styles_emitted {
2063        return Ok(());
2064    }
2065    context.component_asset_styles_emitted = true;
2066    context.writer.write(context.component_asset_style_links)?;
2067    write_component_asset_style_manifest(
2068        context.writer,
2069        context.component_asset_style_manifest,
2070        context.nonce,
2071    )
2072}
2073
2074fn write_webui_template_json_map(
2075    writer: &mut dyn ResponseWriter,
2076    scratch: &mut Vec<u8>,
2077    templates: &[WebUiTemplatePayload<'_>],
2078) -> Result<()> {
2079    writer.write("{")?;
2080    let mut wrote = false;
2081    for template in templates {
2082        if template.template_json.is_empty() {
2083            continue;
2084        }
2085        if wrote {
2086            writer.write(",")?;
2087        }
2088        wrote = true;
2089        write_script_safe_json(writer, scratch, template.tag_name)?;
2090        writer.write(":")?;
2091        write_script_safe_json_str(writer, template.template_json)?;
2092    }
2093    writer.write("}")
2094}
2095
2096fn resolve_value_from_sources<'ctx, 'state>(
2097    path: &str,
2098    loop_vars: &'ctx [LoopBinding<'_, 'state>],
2099    visible_loop_scope: VisibleLoopScope,
2100    local_values: LocalValueSources<'ctx, '_, 'state>,
2101    state: &'state Value,
2102) -> Option<Cow<'ctx, Value>>
2103where
2104    'state: 'ctx,
2105{
2106    if let Some(binding) = loop_vars
2107        .get(visible_loop_scope.start..visible_loop_scope.end)
2108        .and_then(|bindings| bindings.last())
2109    {
2110        let name = binding.name;
2111        if path.len() == name.len() && path == name {
2112            return Some(Cow::Borrowed(binding.value));
2113        }
2114        if path.len() > name.len()
2115            && path.as_bytes().get(name.len()) == Some(&b'.')
2116            && path.starts_with(name)
2117        {
2118            if let Some(value) =
2119                find_value_by_dotted_path_ref(&path[name.len() + 1..], binding.value)
2120            {
2121                return Some(value);
2122            }
2123            return find_value_by_dotted_path_ref(path, state);
2124        }
2125    }
2126
2127    if let Some(first_part) = path.split('.').next() {
2128        if let Some(local_value) = local_values.borrowed.get(first_part) {
2129            if first_part.len() == path.len() {
2130                return Some(Cow::Borrowed(local_value));
2131            }
2132            let remaining = &path[first_part.len() + 1..];
2133            if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
2134                return Some(value);
2135            }
2136            return find_value_by_dotted_path_ref(path, state);
2137        }
2138        if let Some(local_value) = local_values.owned.get(first_part) {
2139            if first_part.len() == path.len() {
2140                return Some(Cow::Borrowed(local_value));
2141            }
2142            let remaining = &path[first_part.len() + 1..];
2143            if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
2144                return Some(value);
2145            }
2146            return find_value_by_dotted_path_ref(path, state);
2147        }
2148
2149        for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2150            .iter()
2151            .rev()
2152        {
2153            if binding.name != first_part {
2154                continue;
2155            }
2156            if first_part.len() == path.len() {
2157                return Some(Cow::Borrowed(binding.value));
2158            }
2159            let remaining = &path[first_part.len() + 1..];
2160            if let Some(value) = find_value_by_dotted_path_ref(remaining, binding.value) {
2161                return Some(value);
2162            }
2163            return find_value_by_dotted_path_ref(path, state);
2164        }
2165    }
2166
2167    find_value_by_dotted_path_ref(path, state)
2168}
2169
2170fn resolve_borrowed_collection<'state>(
2171    path: &str,
2172    loop_vars: &[LoopBinding<'_, 'state>],
2173    visible_loop_scope: VisibleLoopScope,
2174    local_values: LocalValueSources<'_, '_, 'state>,
2175    state: &'state Value,
2176) -> Option<&'state [Value]> {
2177    let first_part = path.split('.').next()?;
2178    if let Some(binding) = loop_vars
2179        .get(visible_loop_scope.start..visible_loop_scope.end)
2180        .and_then(|bindings| bindings.last())
2181        .filter(|binding| binding.name == first_part)
2182    {
2183        if first_part.len() == path.len() {
2184            return binding.value.as_array().map(Vec::as_slice);
2185        }
2186        let remaining = &path[first_part.len() + 1..];
2187        return match find_value_by_dotted_path_ref(remaining, binding.value) {
2188            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2189            Some(Cow::Owned(_)) => None,
2190            None => borrowed_state_array(path, state),
2191        };
2192    }
2193    if let Some(local_value) = local_values.borrowed.get(first_part) {
2194        let local_match = if first_part.len() == path.len() {
2195            Some(Cow::Borrowed(local_value))
2196        } else {
2197            find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
2198        };
2199        return match local_match {
2200            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2201            Some(Cow::Owned(_)) => None,
2202            None => borrowed_state_array(path, state),
2203        };
2204    }
2205    if let Some(local_value) = local_values.owned.get(first_part) {
2206        let local_match = if first_part.len() == path.len() {
2207            Some(Cow::Borrowed(local_value))
2208        } else {
2209            find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
2210        };
2211        if local_match.is_some() {
2212            return None;
2213        }
2214        return borrowed_state_array(path, state);
2215    }
2216
2217    for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2218        .iter()
2219        .rev()
2220    {
2221        if binding.name != first_part {
2222            continue;
2223        }
2224        if first_part.len() == path.len() {
2225            return binding.value.as_array().map(Vec::as_slice);
2226        }
2227        let remaining = &path[first_part.len() + 1..];
2228        return match find_value_by_dotted_path_ref(remaining, binding.value) {
2229            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2230            Some(Cow::Owned(_)) => None,
2231            None => borrowed_state_array(path, state),
2232        };
2233    }
2234
2235    borrowed_state_array(path, state)
2236}
2237
2238fn resolve_state_backed_value<'state>(
2239    path: &str,
2240    loop_vars: &[LoopBinding<'_, 'state>],
2241    visible_loop_scope: VisibleLoopScope,
2242    local_values: LocalValueSources<'_, '_, 'state>,
2243    state: &'state Value,
2244) -> Option<&'state Value> {
2245    let first_part = path.split('.').next()?;
2246    if let Some(binding) = loop_vars
2247        .get(visible_loop_scope.start..visible_loop_scope.end)
2248        .and_then(|bindings| bindings.last())
2249        .filter(|binding| binding.name == first_part)
2250    {
2251        if first_part.len() == path.len() {
2252            return Some(binding.value);
2253        }
2254        let remaining = &path[first_part.len() + 1..];
2255        return match find_value_by_dotted_path_ref(remaining, binding.value) {
2256            Some(Cow::Borrowed(value)) => Some(value),
2257            Some(Cow::Owned(_)) => None,
2258            None => borrowed_state_value(path, state),
2259        };
2260    }
2261    if let Some(local_value) = local_values.borrowed.get(first_part) {
2262        if first_part.len() == path.len() {
2263            return Some(local_value);
2264        }
2265        return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
2266            Some(Cow::Borrowed(value)) => Some(value),
2267            Some(Cow::Owned(_)) => None,
2268            None => borrowed_state_value(path, state),
2269        };
2270    }
2271    if let Some(local_value) = local_values.owned.get(first_part) {
2272        if first_part.len() == path.len() {
2273            return None;
2274        }
2275        return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
2276            Some(_) => None,
2277            None => borrowed_state_value(path, state),
2278        };
2279    }
2280
2281    for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2282        .iter()
2283        .rev()
2284    {
2285        if binding.name != first_part {
2286            continue;
2287        }
2288        if first_part.len() == path.len() {
2289            return Some(binding.value);
2290        }
2291        let remaining = &path[first_part.len() + 1..];
2292        return match find_value_by_dotted_path_ref(remaining, binding.value) {
2293            Some(Cow::Borrowed(value)) => Some(value),
2294            Some(Cow::Owned(_)) => None,
2295            None => borrowed_state_value(path, state),
2296        };
2297    }
2298
2299    borrowed_state_value(path, state)
2300}
2301
2302fn borrowed_state_value<'state>(path: &str, state: &'state Value) -> Option<&'state Value> {
2303    match find_value_by_dotted_path_ref(path, state) {
2304        Some(Cow::Borrowed(value)) => Some(value),
2305        Some(Cow::Owned(_)) | None => None,
2306    }
2307}
2308
2309fn borrowed_state_array<'state>(path: &str, state: &'state Value) -> Option<&'state [Value]> {
2310    match find_value_by_dotted_path_ref(path, state)? {
2311        Cow::Borrowed(value) => value.as_array().map(Vec::as_slice),
2312        Cow::Owned(_) => None,
2313    }
2314}
2315
2316impl WebUIHandler {
2317    /// Create a new WebUI handler with no plugin.
2318    pub fn new() -> Self {
2319        Self {
2320            plugin_factory: None,
2321        }
2322    }
2323
2324    /// Create a new WebUI handler with a plugin factory.
2325    ///
2326    /// Each render call creates a fresh plugin instance from the factory,
2327    /// enabling concurrent renders with `&self`.
2328    pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
2329        Self {
2330            plugin_factory: Some(factory),
2331        }
2332    }
2333
2334    #[cfg(test)]
2335    fn handle(
2336        &self,
2337        document: &WebUIProtocol,
2338        state: &Value,
2339        options: &RenderOptions<'_>,
2340        writer: &mut dyn ResponseWriter,
2341    ) -> Result<()> {
2342        let protocol = Protocol::new(document.clone());
2343        self.render(&protocol, state, options, writer)
2344    }
2345
2346    /// Process a fragment by its prepared render slot, falling back to an ID
2347    /// lookup when the caller has no prepared slot (handler-generated hosts).
2348    ///
2349    /// The `context` parameter contains scope-local variables that are accessible during rendering,
2350    /// such as loop iteration variables. This is separate from the global `state`.
2351    fn process_fragment_target<'data>(
2352        &self,
2353        target: Option<usize>,
2354        fragment_id: &str,
2355        context: &mut WebUIProcessContext<'data, '_, '_>,
2356    ) -> Result<()> {
2357        let Some(index) = target.or_else(|| context.render_fragments.index(fragment_id)) else {
2358            return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2359        };
2360        let Some(fragment_list) = context.render_fragments.list(index) else {
2361            return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2362        };
2363        self.process_fragment(fragment_list, context)
2364    }
2365
2366    /// Process a vector of fragments.
2367    ///
2368    /// The `context` maintains scope-specific variables that can be accessed by fragments
2369    /// during rendering, while `state` contains the global application state.
2370    fn process_fragment<'data>(
2371        &self,
2372        fragment_list: RenderFragmentList<'data>,
2373        context: &mut WebUIProcessContext<'data, '_, '_>,
2374    ) -> Result<()> {
2375        self.process_fragment_from(fragment_list, 0, context)
2376    }
2377
2378    fn process_fragment_from<'data>(
2379        &self,
2380        fragment_list: RenderFragmentList<'data>,
2381        start: usize,
2382        context: &mut WebUIProcessContext<'data, '_, '_>,
2383    ) -> Result<()> {
2384        let fragments = fragment_list.fragments;
2385        // Pre-scan: find the best matching route among sibling routes by specificity.
2386        // This ensures `/contacts/add` (2 literals) beats `/contacts/:id` (1 literal).
2387        // Resolves relative paths (`./`) using the current route_base.
2388        // Lists prepared without any route fragment cannot produce a match, so
2389        // the scan is skipped entirely for them.
2390        let best_route = if fragment_list.has_routes {
2391            route_renderer::find_best_route_match(
2392                fragments,
2393                context.request_path,
2394                &context.route_base,
2395                context.route_index,
2396            )
2397        } else {
2398            None
2399        };
2400        self.process_fragment_range(fragment_list, start..fragments.len(), &best_route, context)
2401    }
2402
2403    fn process_fragment_range<'data>(
2404        &self,
2405        fragment_list: RenderFragmentList<'data>,
2406        range: std::ops::Range<usize>,
2407        best_route: &Option<(String, route_matcher::RouteMatch)>,
2408        context: &mut WebUIProcessContext<'data, '_, '_>,
2409    ) -> Result<()> {
2410        let fragments = fragment_list.fragments;
2411        let Some(selected) = fragments.get(range.clone()) else {
2412            return Err(invalid_fragment_range_error(&range, fragments.len()));
2413        };
2414        for (offset, item) in selected.iter().enumerate() {
2415            let index = range.start + offset;
2416            if context.streaming.is_some() {
2417                validate_pending_streaming_root(item, context)?;
2418                validate_streaming_root_opening(&fragments[..index], item)?;
2419            }
2420            // The prepared target is only read by fragments that descend, so it
2421            // is resolved per-arm. Hoisting it here would charge every raw text
2422            // fragment for a lookup it never uses.
2423            match item.fragment.as_ref() {
2424                Some(Fragment::Raw(raw)) => {
2425                    context.writer.write(&raw.value)?;
2426                }
2427                Some(Fragment::Component(component)) => {
2428                    self.process_component(
2429                        &component.fragment_id,
2430                        fragment_list.target(index),
2431                        ComponentHostOrigin::ParserProduced,
2432                        context,
2433                    )?;
2434                }
2435                Some(Fragment::ForLoop(for_loop)) => {
2436                    self.process_for_loop(for_loop, fragment_list.target(index), context)?;
2437                }
2438                Some(Fragment::Signal(signal)) => {
2439                    self.process_signal(signal, context)?;
2440                }
2441                Some(Fragment::IfCond(if_cond)) => {
2442                    self.process_if(if_cond, fragment_list.target(index), context)?;
2443                }
2444                Some(Fragment::Attribute(attr)) => {
2445                    self.process_attribute(
2446                        attr,
2447                        fragment_list.target(index),
2448                        fragment_list.component_attr_name(index),
2449                        context,
2450                    )?;
2451                }
2452                Some(Fragment::Plugin(plugin_frag)) => {
2453                    if let Some(p) = &mut context.plugin {
2454                        p.on_element_data(&plugin_frag.data, context.writer)?;
2455                    }
2456                }
2457                Some(Fragment::Route(route_frag)) => {
2458                    self.process_route(route_frag, best_route, context)?;
2459                }
2460                Some(Fragment::Outlet(_)) => {
2461                    self.process_outlet(context)?;
2462                }
2463                Some(Fragment::Boundary(_)) => {}
2464                None => {}
2465            }
2466        }
2467        ensure_no_pending_streaming_root(context, "the end of the containing fragment")
2468    }
2469
2470    /// Process an `<outlet />` directive.
2471    ///
2472    /// Matches children from the currently active route's `children` field
2473    /// against the request path, renders the matched child `<webui-route>`
2474    /// elements directly at this position (no wrapper element).
2475    fn process_outlet<'protocol>(
2476        &self,
2477        context: &mut WebUIProcessContext<'protocol, '_, '_>,
2478    ) -> Result<()> {
2479        // Moved out so the matched child can render with the context pointing
2480        // at its grandchildren. The level is deliberately not put back, which
2481        // preserves the previous behavior exactly: a second `<outlet />` at
2482        // this level renders nothing. That is a latent bug tracked by #515, not
2483        // a property this function needs; fixing it belongs in its own change.
2484        let mut children = std::mem::take(&mut context.route_children);
2485        if children.is_empty() {
2486            return Ok(());
2487        }
2488
2489        // Find the best matching child route
2490        let request_segments = route_matcher::split_request_path(context.request_path);
2491        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
2492        for (idx, child) in children.iter().enumerate() {
2493            if let Some(m) = route_matcher::match_route_indexed_with_segments(
2494                context.route_index,
2495                &child.path,
2496                &context.route_base,
2497                &request_segments,
2498                child.exact,
2499            ) {
2500                let is_better = best
2501                    .as_ref()
2502                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
2503                if is_better {
2504                    best = Some((idx, m));
2505                }
2506            }
2507        }
2508
2509        let after_match = best.as_ref().map_or(0, |(idx, _)| idx + 1);
2510        if let Some((idx, ref rm)) = best {
2511            route_renderer::write_hidden_routes(context.writer, &children[..idx])?;
2512            let descended = descend_into(&mut children, idx);
2513            let Some(matched_child) = children.get(idx) else {
2514                return Ok(());
2515            };
2516            let comp = &matched_child.fragment_id;
2517
2518            if !comp.is_empty() {
2519                let saved_route_base = (rm.consumed_segments > 0).then(|| {
2520                    let base = route_matcher::compute_route_base(
2521                        context.request_path,
2522                        rm.consumed_segments,
2523                    );
2524                    std::mem::replace(&mut context.route_base, Cow::Owned(base))
2525                });
2526                context.route_children = descended;
2527
2528                // Emit matched <webui-route>
2529                context.writer.write("<webui-route")?;
2530                context.writer.write(" path=\"")?;
2531                context.writer.write(&matched_child.path)?;
2532                context.writer.write("\"")?;
2533                context.writer.write(" component=\"")?;
2534                context.writer.write(comp)?;
2535                context.writer.write("\"")?;
2536                if matched_child.exact {
2537                    context.writer.write(" exact")?;
2538                }
2539                route_renderer::write_route_navigation_attrs(context.writer, matched_child)?;
2540                // Emit data-ri for O(1) client-side element binding
2541                let ri = context.route_chain_index;
2542                context.route_chain_index += 1;
2543                context.writer.write(" data-ri=\"")?;
2544                write_usize(context.writer, ri)?;
2545                context.writer.write("\" active>")?;
2546
2547                if !Self::component_owns_css_tree(comp, context.protocol) {
2548                    self.emit_component_style_closure(comp, StyleClosureInstall::Routed, context)?;
2549                }
2550                if !matched_child.content_fragment_id.is_empty() {
2551                    self.process_fragment_target(
2552                        None,
2553                        &matched_child.content_fragment_id,
2554                        context,
2555                    )?;
2556                }
2557
2558                context.writer.write("<")?;
2559                context.writer.write(comp)?;
2560                if let Some(p) = &context.plugin {
2561                    p.write_route_component_state(context.state, context.writer)?;
2562                }
2563                write_interaction_marker(comp, context)?;
2564                prepare_generated_streaming_root(comp, context)?;
2565                context.writer.write(">")?;
2566
2567                self.process_component(comp, None, ComponentHostOrigin::HandlerGenerated, context)?;
2568
2569                context.writer.write("</")?;
2570                context.writer.write(comp)?;
2571                context.writer.write(">")?;
2572                context.writer.write("</webui-route>")?;
2573
2574                if let Some(saved) = saved_route_base {
2575                    context.route_base = saved;
2576                }
2577                // Restores the empty level the matched child was rendered
2578                // against, rather than the level this outlet matched. See the
2579                // note at the top of this function and #515.
2580                context.route_children = Cow::Borrowed(&[]);
2581            }
2582        }
2583
2584        route_renderer::write_hidden_routes(context.writer, &children[after_match..])
2585    }
2586
2587    /// Emit a `<script type="importmap">` tag that registers a component's
2588    /// CSS module under its specifier via a `data:text/css,…` URI.
2589    ///
2590    /// Requires Multiple Import Maps (Chrome 133+); each call emits an
2591    /// independent importmap that the browser merges at the document
2592    /// level. The per-render CSP nonce is applied when set (importmap
2593    /// scripts honor `script-src`).
2594    ///
2595    /// Example for `my-comp` with CSS `span{color:blue;}`:
2596    /// `<script type="importmap" nonce="..." data-webui-resource="my-comp">{"imports":{"my-comp":"data:text/css,span{color:blue;}"}}</script>`
2597    fn emit_css_module_importmap(
2598        &self,
2599        specifier: &str,
2600        css: &str,
2601        context: &mut WebUIProcessContext,
2602    ) -> Result<()> {
2603        let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
2604        context.writer.write(&tag)?;
2605        Ok(())
2606    }
2607
2608    /// Install one compiler-ordered closure into the active CSS tree.
2609    fn emit_component_style_closure(
2610        &self,
2611        root: &str,
2612        install: StyleClosureInstall,
2613        context: &mut WebUIProcessContext,
2614    ) -> Result<()> {
2615        if context.protocol.style_closures.is_empty() {
2616            return Ok(());
2617        }
2618        let shadow_static_closure = if install == StyleClosureInstall::Routed {
2619            match context.shadow_style_roots.last() {
2620                Some(shadow_root) => {
2621                    if !shadow_root.static_closure_emitted {
2622                        return Err(HandlerError::Invariant(
2623                            "routed styles reached a ShadowRoot before its static style hook"
2624                                .to_string(),
2625                        ));
2626                    }
2627                    let root_name = context
2628                        .component_index
2629                        .iter()
2630                        .find_map(|(name, index)| {
2631                            (*index == shadow_root.component_index).then_some(name.as_str())
2632                        })
2633                        .ok_or_else(|| {
2634                            HandlerError::Invariant(
2635                                "active Shadow style root lost its protocol index".to_string(),
2636                            )
2637                        })?;
2638                    Some(
2639                        context
2640                            .protocol
2641                            .style_closures
2642                            .get(root_name)
2643                            .ok_or_else(|| {
2644                                HandlerError::Invariant(format!(
2645                            "component style closure metadata is missing Shadow root `{root_name}`"
2646                        ))
2647                            })?,
2648                    )
2649                }
2650                None => None,
2651            }
2652        } else {
2653            None
2654        };
2655        let closure = context.protocol.style_closures.get(root).ok_or_else(|| {
2656            HandlerError::Invariant(format!(
2657                "component style closure metadata is missing root `{root}`"
2658            ))
2659        })?;
2660        let is_document_tree = context.shadow_style_roots.is_empty();
2661        let strategy = context.css_strategy;
2662
2663        // A bundled build delivers merged chunks; otherwise every component
2664        // delivers its own stylesheet. Both walk the closure in the same order,
2665        // so the emitted cascade is identical either way. Bundling is a
2666        // build-wide decision, so the two never mix within one protocol.
2667        let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2668        let mut emitted_resources = EmittedStyleUnits::new();
2669
2670        for position in 0..unit_count {
2671            let unit = context
2672                .protocol
2673                .style_closure_unit(closure, &context.style_chunk_index, position)
2674                .ok_or_else(|| {
2675                    HandlerError::Invariant(format!(
2676                        "component style closure `{root}` references out-of-range unit {position}"
2677                    ))
2678                })?;
2679            let (name, chunk) = (unit.name, unit.chunk);
2680            if !emitted_resources.insert(name) {
2681                continue;
2682            }
2683            let resource = unit.resource.ok_or_else(|| match chunk {
2684                Some(index) => HandlerError::Invariant(format!(
2685                    "component style closure `{root}` references missing style chunk {index}"
2686                )),
2687                None => HandlerError::Invariant(format!(
2688                    "component style closure `{root}` references missing resource `{name}`"
2689                )),
2690            })?;
2691            if is_document_tree && !context.document_style_resources.insert(name.to_string()) {
2692                continue;
2693            }
2694            if let Some(static_closure) = shadow_static_closure {
2695                let resource_index = match chunk {
2696                    Some(index) => {
2697                        if static_closure.style_chunks.contains(&index) {
2698                            continue;
2699                        }
2700                        index
2701                    }
2702                    None => {
2703                        if static_closure.component_tags.iter().any(|tag| tag == name) {
2704                            continue;
2705                        }
2706                        context.component_index.get(name).copied().ok_or_else(|| {
2707                            HandlerError::Invariant(format!(
2708                                "component style resource `{name}` is missing its protocol index"
2709                            ))
2710                        })?
2711                    }
2712                };
2713                let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2714                    HandlerError::Invariant(
2715                        "active Shadow style root disappeared during routed style delivery"
2716                            .to_string(),
2717                    )
2718                })?;
2719                if shadow_root.routed_resources.contains(&resource_index) {
2720                    continue;
2721                }
2722                shadow_root.routed_resources.push(resource_index);
2723            }
2724
2725            match strategy {
2726                webui_protocol::CssStrategy::Link => {
2727                    context.writer.write("<link rel=\"stylesheet\" href=\"")?;
2728                    context
2729                        .writer
2730                        .write(&crate::html_encode::encode_safe(resource))?;
2731                    context.writer.write("\" data-webui-resource=\"")?;
2732                    context
2733                        .writer
2734                        .write(&crate::html_encode::encode_safe(name))?;
2735                    context.writer.write("\" data-webui-strategy=\"link\">")?;
2736                }
2737                strategy => {
2738                    context.writer.write("<style")?;
2739                    if let Some(nonce) = context.nonce {
2740                        context.writer.write(" nonce=\"")?;
2741                        context
2742                            .writer
2743                            .write(&crate::html_encode::encode_safe(nonce))?;
2744                        context.writer.write("\"")?;
2745                    }
2746                    context.writer.write(" data-webui-resource=\"")?;
2747                    context
2748                        .writer
2749                        .write(&crate::html_encode::encode_safe(name))?;
2750                    context.writer.write("\" data-webui-strategy=\"")?;
2751                    context
2752                        .writer
2753                        .write(if strategy == webui_protocol::CssStrategy::Module {
2754                            "module"
2755                        } else {
2756                            "style"
2757                        })?;
2758                    context.writer.write("\">")?;
2759                    if context.style_resources_requiring_escape.contains(name) {
2760                        crate::html_encode::write_style_text(context.writer, resource)?;
2761                    } else {
2762                        context.writer.write(resource)?;
2763                    }
2764                    context.writer.write("</style>")?;
2765                }
2766            }
2767        }
2768        Ok(())
2769    }
2770
2771    pub(crate) fn ensure_request_route_chain(context: &mut WebUIProcessContext) {
2772        if context.route_chain.is_none() {
2773            let plan = crate::route_handler::collect_route_chain_plan(
2774                context.protocol,
2775                context.entry_id,
2776                context.request_path,
2777                context.route_index,
2778            );
2779            context.route_document_style_targets = plan.document_style_targets;
2780            context.route_chain = Some(plan.entries);
2781        }
2782    }
2783
2784    fn emit_active_route_styles(
2785        &self,
2786        preloaded: &mut Vec<u32>,
2787        context: &mut WebUIProcessContext,
2788    ) -> Result<()> {
2789        Self::ensure_request_route_chain(context);
2790        let Some(chain) = context.route_chain.take() else {
2791            return Err(route_style_plan_missing_error());
2792        };
2793        let document_targets = std::mem::take(&mut context.route_document_style_targets);
2794        if chain.len() != document_targets.len() {
2795            let error = route_style_plan_length_error(chain.len(), document_targets.len());
2796            context.route_document_style_targets = document_targets;
2797            context.route_chain = Some(chain);
2798            return Err(error);
2799        }
2800
2801        let result = (|| {
2802            for (entry, targets_document) in chain.iter().zip(&document_targets) {
2803                if *targets_document {
2804                    self.emit_component_style_closure(
2805                        &entry.component,
2806                        StyleClosureInstall::Static,
2807                        context,
2808                    )?;
2809                    continue;
2810                }
2811
2812                if context.css_strategy != webui_protocol::CssStrategy::Link {
2813                    continue;
2814                }
2815                self.emit_component_style_preloads(&entry.component, preloaded, context)?;
2816            }
2817            Ok(())
2818        })();
2819
2820        context.route_document_style_targets = document_targets;
2821        context.route_chain = Some(chain);
2822        result
2823    }
2824
2825    fn emit_component_style_preloads(
2826        &self,
2827        root: &str,
2828        preloaded: &mut Vec<u32>,
2829        context: &mut WebUIProcessContext,
2830    ) -> Result<()> {
2831        let Some(closure) = context.protocol.style_closures.get(root) else {
2832            return Ok(());
2833        };
2834        // A chunk shared by several tree-local roots needs one preload.
2835        let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2836        for position in 0..unit_count {
2837            let Some(unit) =
2838                context
2839                    .protocol
2840                    .style_closure_unit(closure, &context.style_chunk_index, position)
2841            else {
2842                continue;
2843            };
2844            let Some(href) = unit.resource else {
2845                continue;
2846            };
2847            let name = unit.name;
2848            if context.document_style_resources.contains(name) {
2849                continue;
2850            }
2851            let Some(&resource_index) = context.style_resource_index.get(name) else {
2852                continue;
2853            };
2854            if preloaded.contains(&resource_index) {
2855                continue;
2856            }
2857            context
2858                .writer
2859                .write("<link rel=\"preload\" as=\"style\" href=\"")?;
2860            context
2861                .writer
2862                .write(&crate::html_encode::encode_safe(href))?;
2863            context.writer.write("\">")?;
2864            preloaded.push(resource_index);
2865        }
2866        Ok(())
2867    }
2868
2869    fn emit_reachable_shadow_preloads(
2870        &self,
2871        preloaded: &mut Vec<u32>,
2872        context: &mut WebUIProcessContext,
2873    ) -> Result<()> {
2874        if context.css_strategy != webui_protocol::CssStrategy::Link {
2875            return Ok(());
2876        }
2877        let reachable = context.reachable_components.take().unwrap_or_else(|| {
2878            crate::route_handler::collect_reachable_component_order_for_request(
2879                context.protocol,
2880                context.entry_id,
2881                context.request_path,
2882                context.route_index,
2883            )
2884        });
2885        let result = (|| {
2886            for component in &reachable {
2887                if context.protocol.component_uses_shadow_dom(component) {
2888                    self.emit_component_style_preloads(component, preloaded, context)?;
2889                }
2890            }
2891            Ok(())
2892        })();
2893        context.reachable_components = Some(reachable);
2894        result
2895    }
2896
2897    fn process_shadow_style_signal(
2898        &self,
2899        root: &str,
2900        context: &mut WebUIProcessContext,
2901    ) -> Result<()> {
2902        let Some(root_index) = context.component_index.get(root).copied() else {
2903            return Err(HandlerError::Invariant(format!(
2904                "Shadow style hook `{root}` references an unknown component"
2905            )));
2906        };
2907        if context
2908            .shadow_style_roots
2909            .last()
2910            .map(|shadow_root| shadow_root.component_index)
2911            != Some(root_index)
2912        {
2913            return Err(HandlerError::Invariant(format!(
2914                "Shadow style hook `{root}` does not match the active component root"
2915            )));
2916        }
2917        self.emit_component_style_closure(root, StyleClosureInstall::Static, context)?;
2918        let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2919            HandlerError::Invariant(
2920                "active Shadow style root disappeared after its static style hook".to_string(),
2921            )
2922        })?;
2923        shadow_root.static_closure_emitted = true;
2924        Ok(())
2925    }
2926
2927    /// Emit a component's CSS module importmap on its first render
2928    /// (deduped by `rendered_components`) into the component's light DOM,
2929    /// so the browser registers it under the component's specifier
2930    /// before the shadow root template is parsed. See
2931    /// [`Self::emit_css_module_importmap`] for the emitted shape.
2932    ///
2933    /// Only components rendered on the current route get inline definitions;
2934    /// navigation responses carry later definitions in `componentStyles`.
2935    fn emit_css_module(&self, fragment_id: &str, context: &mut WebUIProcessContext) -> Result<()> {
2936        if context.css_strategy != webui_protocol::CssStrategy::Module {
2937            return Ok(());
2938        }
2939        let metadata_already_streamed = context.streaming.as_ref().is_some_and(|streaming| {
2940            streaming_template_already_sent(streaming, context.component_index, fragment_id)
2941        });
2942        if !metadata_already_streamed && !context.rendered_components.contains(fragment_id) {
2943            if let Some(css) = context
2944                .protocol
2945                .components
2946                .get(fragment_id)
2947                .map(|c| c.css.as_str())
2948                .filter(|s| !s.is_empty())
2949            {
2950                self.emit_css_module_importmap(fragment_id, css, context)?;
2951            }
2952        }
2953        Ok(())
2954    }
2955
2956    /// Process a route fragment — renders `<webui-route>` with matched/hidden state.
2957    fn process_route<'protocol>(
2958        &self,
2959        route_frag: &'protocol webui_protocol::WebUiFragmentRoute,
2960        best_route: &Option<(String, route_matcher::RouteMatch)>,
2961        context: &mut WebUIProcessContext<'protocol, '_, '_>,
2962    ) -> Result<()> {
2963        let is_matched = best_route
2964            .as_ref()
2965            .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
2966
2967        context.writer.write("<webui-route")?;
2968        context.writer.write(" path=\"")?;
2969        context.writer.write(&route_frag.path)?;
2970        context.writer.write("\"")?;
2971        if !route_frag.fragment_id.is_empty() {
2972            context.writer.write(" component=\"")?;
2973            context.writer.write(&route_frag.fragment_id)?;
2974            context.writer.write("\"")?;
2975        }
2976        if route_frag.exact {
2977            context.writer.write(" exact")?;
2978        }
2979        route_renderer::write_route_navigation_attrs(context.writer, route_frag)?;
2980
2981        if is_matched {
2982            // Emit data-ri for O(1) client-side element binding
2983            let ri = context.route_chain_index;
2984            context.route_chain_index += 1;
2985            context.writer.write(" data-ri=\"")?;
2986            write_usize(context.writer, ri)?;
2987            context.writer.write("\" active>")?;
2988
2989            let saved_route_base = best_route.as_ref().map(|(_, rm)| {
2990                let base =
2991                    route_matcher::compute_route_base(context.request_path, rm.consumed_segments);
2992                std::mem::replace(&mut context.route_base, Cow::Owned(base))
2993            });
2994            let saved_route_children = std::mem::replace(
2995                &mut context.route_children,
2996                Cow::Borrowed(&route_frag.children),
2997            );
2998
2999            if !route_frag.content_fragment_id.is_empty() {
3000                self.process_fragment_target(None, &route_frag.content_fragment_id, context)?;
3001            }
3002
3003            if !route_frag.fragment_id.is_empty() {
3004                if !Self::component_owns_css_tree(&route_frag.fragment_id, context.protocol) {
3005                    self.emit_component_style_closure(
3006                        &route_frag.fragment_id,
3007                        StyleClosureInstall::Routed,
3008                        context,
3009                    )?;
3010                }
3011                context.writer.write("<")?;
3012                context.writer.write(&route_frag.fragment_id)?;
3013                if let Some(p) = &context.plugin {
3014                    p.write_route_component_state(context.state, context.writer)?;
3015                }
3016                write_interaction_marker(&route_frag.fragment_id, context)?;
3017                prepare_generated_streaming_root(&route_frag.fragment_id, context)?;
3018                context.writer.write(">")?;
3019
3020                self.process_component(
3021                    &route_frag.fragment_id,
3022                    None,
3023                    ComponentHostOrigin::HandlerGenerated,
3024                    context,
3025                )?;
3026
3027                context.writer.write("</")?;
3028                context.writer.write(&route_frag.fragment_id)?;
3029                context.writer.write(">")?;
3030            }
3031            if let Some(saved) = saved_route_base {
3032                context.route_base = saved;
3033            }
3034            context.route_children = saved_route_children;
3035        } else {
3036            context.writer.write(" style=\"display:none\">")?;
3037        }
3038
3039        context.writer.write("</webui-route>")?;
3040        Ok(())
3041    }
3042
3043    /// Process a component fragment.
3044    fn process_component(
3045        &self,
3046        fragment_id: &str,
3047        target: Option<usize>,
3048        origin: ComponentHostOrigin,
3049        context: &mut WebUIProcessContext,
3050    ) -> Result<()> {
3051        if context.streaming.is_some() {
3052            consume_streaming_component_root(fragment_id, origin, context)?;
3053            // Capture only after root parity succeeds, so malformed protocols
3054            // cannot contribute unmarked hosts to a checkpoint.
3055            record_checkpoint_tag(context, fragment_id);
3056        }
3057
3058        // Emit the component's CSS module importmap into its light DOM and track
3059        // the component as rendered on first encounter only. `rendered_components`
3060        // is a set, so gating the `insert` (and its `String` clone) behind the
3061        // first-encounter check avoids allocating a throwaway `String` for every
3062        // duplicate instance while keeping the set contents identical.
3063        if !context.rendered_components.contains(fragment_id) {
3064            self.emit_css_module(fragment_id, context)?;
3065            context.rendered_components.insert(fragment_id.to_string());
3066        }
3067
3068        let owns_css_tree = Self::component_owns_css_tree(fragment_id, context.protocol);
3069        if owns_css_tree {
3070            Self::push_shadow_style_root(fragment_id, context)?;
3071        }
3072
3073        // Save parent scope. `mem::take` leaves an alloc-free empty map behind.
3074        let saved_local_vars = std::mem::take(&mut context.local_vars);
3075        let saved_local_borrowed_vars = std::mem::take(&mut context.local_borrowed_vars);
3076        // The component's accumulated attrs become its local vars; the next
3077        // sibling accumulates into a recycled (capacity-preserving) map from the
3078        // request-local pool instead of a freshly allocated `HashMap`.
3079        let saved_component_attrs = std::mem::replace(
3080            &mut context.component_attrs,
3081            take_scope_map(&mut context.scope_pool),
3082        );
3083        let saved_component_borrowed_attrs = std::mem::replace(
3084            &mut context.component_borrowed_attrs,
3085            take_borrowed_scope(&mut context.borrowed_scope_pool),
3086        );
3087        context.local_vars = saved_component_attrs;
3088        context.local_borrowed_vars = saved_component_borrowed_attrs;
3089        context.collecting_component_attrs = false;
3090        let saved_loop_scope = context.visible_loop_scope;
3091        context.visible_loop_scope = VisibleLoopScope {
3092            start: context.loop_vars.len(),
3093            end: context.loop_vars.len(),
3094        };
3095
3096        if let Some(p) = &mut context.plugin {
3097            p.push_scope();
3098        }
3099
3100        let render_result = self.process_fragment_target(target, fragment_id, context);
3101        context.visible_loop_scope = saved_loop_scope;
3102
3103        if owns_css_tree {
3104            Self::pop_shadow_style_root(fragment_id, context)?;
3105        }
3106        render_result?;
3107
3108        if let Some(p) = &mut context.plugin {
3109            p.pop_scope();
3110        }
3111
3112        // Restore parent scope, recycling this component's local map (its
3113        // accumulated attrs) back into the pool so a sibling reuses its capacity.
3114        let used_locals = std::mem::replace(&mut context.local_vars, saved_local_vars);
3115        recycle_scope_map(&mut context.scope_pool, used_locals);
3116        let used_borrowed_locals =
3117            std::mem::replace(&mut context.local_borrowed_vars, saved_local_borrowed_vars);
3118        recycle_borrowed_scope(&mut context.borrowed_scope_pool, used_borrowed_locals);
3119        // The attr accumulator (pulled from the pool above) is cleared for the
3120        // next sibling while retaining its bucket capacity.
3121        context.component_attrs.clear();
3122        context.component_borrowed_attrs.clear();
3123
3124        Ok(())
3125    }
3126
3127    #[inline]
3128    pub(crate) fn component_owns_css_tree(component: &str, protocol: &WebUIProtocol) -> bool {
3129        !protocol.style_closures.is_empty()
3130            && protocol
3131                .components
3132                .get(component)
3133                .is_some_and(|data| data.uses_shadow_dom)
3134    }
3135
3136    pub(crate) fn push_shadow_style_root(
3137        component: &str,
3138        context: &mut WebUIProcessContext,
3139    ) -> Result<()> {
3140        if !context.protocol.style_closures.contains_key(component) {
3141            return Err(HandlerError::Invariant(format!(
3142                "component style closure metadata is missing Shadow root `{component}`"
3143            )));
3144        }
3145        let root_index = context
3146            .component_index
3147            .get(component)
3148            .copied()
3149            .ok_or_else(|| {
3150                HandlerError::Invariant(format!(
3151                    "Shadow component `{component}` is missing its protocol index"
3152                ))
3153            })?;
3154        context.shadow_style_roots.push(ShadowStyleRoot {
3155            component_index: root_index,
3156            static_closure_emitted: false,
3157            routed_resources: Vec::new(),
3158        });
3159        Ok(())
3160    }
3161
3162    pub(crate) fn pop_shadow_style_root(
3163        component: &str,
3164        context: &mut WebUIProcessContext,
3165    ) -> Result<()> {
3166        context.shadow_style_roots.pop().ok_or_else(|| {
3167            HandlerError::Invariant(format!(
3168                "Shadow component `{component}` lost its active style root"
3169            ))
3170        })?;
3171        Ok(())
3172    }
3173
3174    /// Resolve a dotted path into owned state for values retained by the context.
3175    fn resolve_value_owned(
3176        &self,
3177        path: &str,
3178        context: &WebUIProcessContext<'_, '_, '_>,
3179    ) -> Option<Value> {
3180        resolve_value_from_sources(
3181            path,
3182            &context.loop_vars,
3183            context.visible_loop_scope,
3184            LocalValueSources {
3185                owned: &context.local_vars,
3186                borrowed: &context.local_borrowed_vars,
3187            },
3188            context.state,
3189        )
3190        .map(Cow::into_owned)
3191    }
3192
3193    /// Evaluate a condition expression against the current context.
3194    ///
3195    /// Uses a resolver closure that checks local variables first, then falls
3196    /// back to global state — avoiding a full clone of the state tree.
3197    /// Missing identifier operands are falsy before logical operators are applied.
3198    /// Missing predicate values make the complete condition false.
3199    fn evaluate_condition(
3200        &self,
3201        condition: &webui_protocol::ConditionExpr,
3202        context: &WebUIProcessContext,
3203    ) -> Result<bool> {
3204        let loop_vars = &context.loop_vars;
3205        let visible_loop_scope = context.visible_loop_scope;
3206        let local_values = LocalValueSources {
3207            owned: &context.local_vars,
3208            borrowed: &context.local_borrowed_vars,
3209        };
3210        let state = context.state;
3211        match evaluate_with_resolver(condition, |path| {
3212            resolve_value_from_sources(path, loop_vars, visible_loop_scope, local_values, state)
3213        }) {
3214            Ok(result) => Ok(result),
3215            Err(ExpressionError::MissingValue(_)) => Ok(false),
3216            Err(e) => Err(HandlerError::Evaluation(e.to_string())),
3217        }
3218    }
3219
3220    /// Process a for loop fragment.
3221    ///
3222    /// Creates a new context for each iteration that includes the current loop item.
3223    /// This allows nested templates to access both the loop variable and any parent context.
3224    /// Example: `for item in items` makes "item" available in the loop body.
3225    fn process_for_loop<'protocol, 'state>(
3226        &self,
3227        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3228        target: Option<usize>,
3229        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3230    ) -> Result<()> {
3231        if let Some(items) = resolve_borrowed_collection(
3232            &for_loop.collection,
3233            &context.loop_vars,
3234            context.visible_loop_scope,
3235            LocalValueSources {
3236                owned: &context.local_vars,
3237                borrowed: &context.local_borrowed_vars,
3238            },
3239            context.state,
3240        ) {
3241            return self.process_borrowed_for_loop(for_loop, target, items, context);
3242        }
3243        self.process_owned_for_loop(for_loop, target, context)
3244    }
3245
3246    fn process_borrowed_for_loop<'protocol, 'state>(
3247        &self,
3248        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3249        target: Option<usize>,
3250        items: &'state [Value],
3251        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3252    ) -> Result<()> {
3253        if let Some(plugin) = &mut context.plugin {
3254            plugin.on_for_start(&for_loop.fragment_id, context.writer)?;
3255        }
3256
3257        let item_name = for_loop.item.as_str();
3258        let saved_value = context.local_vars.remove(item_name);
3259        let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3260        let saved_scope = context.visible_loop_scope;
3261        for (index, item) in items.iter().enumerate() {
3262            if let Some(plugin) = &mut context.plugin {
3263                plugin.on_repeat_item_start(index, context.writer)?;
3264                plugin.push_scope();
3265            }
3266
3267            context.loop_vars.push(LoopBinding {
3268                name: item_name,
3269                value: item,
3270            });
3271            context.visible_loop_scope.end = context.loop_vars.len();
3272            self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3273            context.loop_vars.pop();
3274            context.visible_loop_scope = saved_scope;
3275
3276            if let Some(plugin) = &mut context.plugin {
3277                plugin.pop_scope();
3278                plugin.on_repeat_item_end(index, context.writer)?;
3279            }
3280        }
3281        if let Some(value) = saved_value {
3282            context.local_vars.insert(item_name.to_string(), value);
3283        }
3284        if let Some(value) = saved_borrowed_value {
3285            context.local_borrowed_vars.insert(item_name, value);
3286        }
3287
3288        if let Some(plugin) = &mut context.plugin {
3289            plugin.on_for_end(&for_loop.fragment_id, context.writer)?;
3290        }
3291        Ok(())
3292    }
3293
3294    fn process_owned_for_loop<'protocol, 'state>(
3295        &self,
3296        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3297        target: Option<usize>,
3298        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3299    ) -> Result<()> {
3300        let collection_name = &for_loop.collection;
3301
3302        // If the collection is missing, treat it as empty (0 iterations) — matches NodeJS behavior.
3303        // Hydration comments are always emitted regardless of collection presence.
3304        let items = match self.resolve_value_owned(collection_name, context) {
3305            Some(Value::Array(arr)) => arr,
3306            Some(_) => {
3307                return Err(HandlerError::TypeError(format!(
3308                    "Collection '{}' is not an array",
3309                    collection_name
3310                )))
3311            }
3312            None => Vec::new(),
3313        };
3314
3315        if let Some(p) = &mut context.plugin {
3316            p.on_for_start(&for_loop.fragment_id, context.writer)?;
3317        }
3318
3319        // Hot-loop optimisation: the loop variable name is `String`-keyed
3320        // in `local_vars`. The naive impl re-inserts (and so re-allocates
3321        // the key) on every iteration — a 1000-item loop pays 2000 String
3322        // clones for the key alone. Instead, we save the outer-scope
3323        // value (if any) ONCE before the loop, install the key ONCE with
3324        // an empty placeholder, then overwrite the value in-place each
3325        // iteration via `get_mut`. Restoration at the end happens once.
3326        let item_name = for_loop.item.as_str();
3327        let saved_value = context.local_vars.remove(item_name);
3328        let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3329        // Pre-insert the key so per-iteration `get_mut` is infallible.
3330        // Cost: at most one `String::from(item_name)` for the lifetime
3331        // of the loop, regardless of iteration count.
3332        if !items.is_empty() {
3333            context
3334                .local_vars
3335                .insert(item_name.to_string(), Value::Null);
3336        }
3337        for (i, item) in items.into_iter().enumerate() {
3338            if let Some(p) = &mut context.plugin {
3339                p.on_repeat_item_start(i, context.writer)?;
3340                p.push_scope();
3341            }
3342
3343            // O(1) value swap; no key allocation.
3344            if let Some(slot) = context.local_vars.get_mut(item_name) {
3345                *slot = item;
3346            }
3347            self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3348
3349            if let Some(p) = &mut context.plugin {
3350                p.pop_scope();
3351                p.on_repeat_item_end(i, context.writer)?;
3352            }
3353        }
3354        // Restore outer scope (or remove the placeholder we installed).
3355        match saved_value {
3356            Some(value) => {
3357                context.local_vars.insert(item_name.to_string(), value);
3358            }
3359            None => {
3360                context.local_vars.remove(item_name);
3361            }
3362        }
3363        if let Some(value) = saved_borrowed_value {
3364            context.local_borrowed_vars.insert(item_name, value);
3365        }
3366
3367        if let Some(p) = &mut context.plugin {
3368            p.on_for_end(&for_loop.fragment_id, context.writer)?;
3369        }
3370
3371        Ok(())
3372    }
3373
3374    /// Process a signal fragment.
3375    ///
3376    /// Looks up the value in the context first (for local variables), then in the global state.
3377    /// This prioritization allows local variables (like loop items) to override global state.
3378    /// If the value is not found in either scope, an empty string is returned.
3379    #[inline]
3380    fn process_signal<'data>(
3381        &self,
3382        signal: &'data webui_protocol::WebUIFragmentSignal,
3383        context: &mut WebUIProcessContext<'data, '_, '_>,
3384    ) -> Result<()> {
3385        if signal.raw {
3386            self.process_raw_signal(signal, context)
3387        } else {
3388            self.process_state_signal(signal, false, context)
3389        }
3390    }
3391
3392    #[inline(never)]
3393    fn process_raw_signal<'data>(
3394        &self,
3395        signal: &'data webui_protocol::WebUIFragmentSignal,
3396        context: &mut WebUIProcessContext<'data, '_, '_>,
3397    ) -> Result<()> {
3398        let Some(structural_value) = structural_signal_value(signal) else {
3399            return self.process_state_signal(signal, !signal.raw_text_context, context);
3400        };
3401
3402        if let Some(root) = structural_value.strip_prefix("shadow_styles:") {
3403            return self.process_shadow_style_signal(root, context);
3404        }
3405
3406        if context.streaming.is_some()
3407            && self.process_streaming_signal(structural_value, context)?
3408        {
3409            return Ok(());
3410        }
3411
3412        // Hook: emit nonce meta and Document-owned CSS before </head>.
3413        // Guarded by `head_end_emitted` so a malformed protocol cannot
3414        // emit nonce/preloads/inject more than once per render.
3415        if structural_value == "head_end" && !context.head_end_emitted {
3416            context.head_end_emitted = true;
3417            if let Some(nonce) = context.nonce {
3418                context
3419                    .writer
3420                    .write("<meta name=\"webui-nonce\" content=\"")?;
3421                context
3422                    .writer
3423                    .write(&crate::html_encode::encode_safe(nonce))?;
3424                context.writer.write("\">")?;
3425            }
3426
3427            // Render-policy CSS is emitted before component styles so an
3428            // authored declaration still wins on a tie.
3429            if !context.protocol.component_render_css.is_empty() {
3430                context.writer.write("<style data-webui-render-policy")?;
3431                if let Some(nonce) = context.nonce {
3432                    context.writer.write(" nonce=\"")?;
3433                    context
3434                        .writer
3435                        .write(&crate::html_encode::encode_safe(nonce))?;
3436                    context.writer.write("\"")?;
3437                }
3438                context.writer.write(">")?;
3439                context
3440                    .writer
3441                    .write(&context.protocol.component_render_css)?;
3442                context.writer.write("</style>")?;
3443            }
3444
3445            if !context.protocol.style_closures.is_empty() {
3446                let entry_id = context.entry_id;
3447                self.emit_component_style_closure(entry_id, StyleClosureInstall::Static, context)?;
3448                let mut preloaded = Vec::new();
3449                self.emit_active_route_styles(&mut preloaded, context)?;
3450                self.emit_reachable_shadow_preloads(&mut preloaded, context)?;
3451            }
3452
3453            // Compiler-resolved `modulepreload` hints for the shared chunks
3454            // the page's module entries statically import. Those chunks are
3455            // named only inside the entry's own bytes, so without this the
3456            // browser must download and parse the entry before it can even
3457            // discover them.
3458            //
3459            // Emitted after the CSS links on purpose: CSS is render-blocking
3460            // and owns first paint, while these own first interaction, so the
3461            // stylesheet requests go out first. The list arrives pre-ordered
3462            // (largest chunk first) and pre-resolved from the build, so this
3463            // is a straight write with no per-request work.
3464            if !context.protocol.module_preloads.is_empty() {
3465                for href in &context.protocol.module_preloads {
3466                    context
3467                        .writer
3468                        .write("<link rel=\"modulepreload\" href=\"")?;
3469                    context.writer.write(href)?;
3470                    context.writer.write("\">")?;
3471                }
3472            }
3473
3474            write_component_asset_styles(context)?;
3475
3476            // Per-render `head_inject` HTML — image preloads, A/B test
3477            // markers, etc. supplied by the host via RenderOptions.
3478            // Emitted at the structural head_end boundary, after the
3479            // built-in nonce + CSS-link emissions, so host injects
3480            // appear immediately before `</head>`.
3481            if let Some(html) = context.head_inject {
3482                context.writer.write(html)?;
3483            }
3484
3485            // Reserved-state `headEnd` HTML, last at this boundary so a
3486            // host that sets both channels gets a deterministic order:
3487            // built-in emissions, then `RenderOptions`, then state.
3488            if let Some(html) = context.state_inject.head_end {
3489                context.writer.write(html)?;
3490            }
3491        }
3492
3493        // Hook: emit state-supplied HTML immediately after `<body>`. Guarded
3494        // by its own dedup flag so a malformed protocol cannot duplicate it.
3495        if structural_value == "body_start" && !context.body_start_emitted {
3496            context.body_start_emitted = true;
3497            write_component_asset_styles(context)?;
3498            if let Some(html) = context.state_inject.body_start {
3499                context.writer.write(html)?;
3500            }
3501        }
3502
3503        // Hook: emit component templates and host body_inject before </body>.
3504        // Single guarded block so the dedup flag protects both the
3505        // hydration emission and the host inject from a malformed
3506        // protocol that fires `body_end` more than once per render.
3507        if structural_value == "body_end" && !context.body_end_emitted {
3508            context.body_end_emitted = true;
3509            if context.plugin.is_some() {
3510                // Emit templates for all REACHABLE components on the current route,
3511                // not just those rendered in this SSR pass. Components inside false
3512                // <if> blocks or empty <for> loops are reachable via client-side
3513                // state changes and need their templates available without a server
3514                // round-trip. The graph walker follows conditional and loop branches
3515                // unconditionally, but only descends into the matched route chain —
3516                // components on other routes are delivered via SPA partial navigation.
3517                // Kept as the traversal-ordered `Vec` produced upstream (already
3518                // deduplicated) rather than collected into a `HashSet`: downstream
3519                // `<head>` CSS `<link>`/style-module emission order must stay
3520                // deterministic across renders, not vary with the process's
3521                // randomized hash seed.
3522                let reachable = context.reachable_components.take().unwrap_or_else(|| {
3523                    crate::route_handler::collect_reachable_component_order_for_request(
3524                        context.protocol,
3525                        context.entry_id,
3526                        context.request_path,
3527                        context.route_index,
3528                    )
3529                });
3530                let state_selection =
3531                    collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
3532
3533                // Emit CSS module importmaps for reachable-but-unrendered
3534                // components so the framework can adopt them when an `<if>`
3535                // condition flips true client-side.
3536                if context.css_strategy == webui_protocol::CssStrategy::Module {
3537                    for name in &reachable {
3538                        if !context.rendered_components.contains(name) {
3539                            if let Some(css) = context
3540                                .protocol
3541                                .components
3542                                .get(name)
3543                                .map(|c| c.css.as_str())
3544                                .filter(|s| !s.is_empty())
3545                            {
3546                                self.emit_css_module_importmap(name, css, context)?;
3547                            }
3548                        }
3549                    }
3550                }
3551
3552                // Try to collect split WebUI template payloads. If the plugin
3553                // returns None (non-WebUI templates, e.g. FAST), fall back to
3554                // separate emission.
3555                let template_payloads = context
3556                    .plugin
3557                    .as_ref()
3558                    .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
3559
3560                if template_payloads.is_none() {
3561                    // Non-JS templates (FAST plugins) - emit separately
3562                    if let Some(ref p) = context.plugin {
3563                        p.emit_templates(
3564                            context.protocol,
3565                            &reachable,
3566                            context.nonce,
3567                            context.writer,
3568                        )?;
3569                    }
3570                }
3571
3572                // Compute the inventory hex from actually rendered components.
3573                let inventory_hex = crate::route_handler::encode_component_inventory(
3574                    &context.rendered_components,
3575                    context.component_index,
3576                );
3577
3578                // Chain
3579                Self::ensure_request_route_chain(context);
3580                let chain = context.route_chain.as_deref().ok_or_else(|| {
3581                    HandlerError::Invariant(
3582                        "request route chain disappeared after collection".to_string(),
3583                    )
3584                })?;
3585                let chain_json: Vec<Value> = chain
3586                    .iter()
3587                    .map(crate::route_handler::RouteChainEntry::to_json)
3588                    .collect();
3589
3590                // CSS hrefs emitted during SSR (Link-strategy components)
3591                let is_link = context.css_strategy == webui_protocol::CssStrategy::Link;
3592                let mut css_hrefs: Vec<&str> = Vec::new();
3593                if is_link {
3594                    for name in &reachable {
3595                        if let Some(href) = context
3596                            .protocol
3597                            .components
3598                            .get(name)
3599                            .map(|c| c.css_href.as_str())
3600                            .filter(|h| !h.is_empty())
3601                        {
3602                            css_hrefs.push(href);
3603                        }
3604                    }
3605                }
3606
3607                // Module style specifiers emitted during SSR
3608                let mut style_specs: Vec<&str> = Vec::new();
3609                if context.css_strategy == webui_protocol::CssStrategy::Module {
3610                    for name in &reachable {
3611                        if context
3612                            .protocol
3613                            .components
3614                            .get(name)
3615                            .map(|c| !c.css.is_empty())
3616                            .unwrap_or(false)
3617                        {
3618                            style_specs.push(name);
3619                        }
3620                    }
3621                }
3622
3623                let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
3624                let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
3625                let mut style_roots = Vec::with_capacity(reachable.len() + chain.len() * 3 + 1);
3626                style_roots.push(context.entry_id);
3627                for entry in chain {
3628                    style_roots.push(entry.component.as_str());
3629                    if !entry.pending_component.is_empty() {
3630                        style_roots.push(entry.pending_component.as_str());
3631                    }
3632                    if !entry.error_component.is_empty() {
3633                        style_roots.push(entry.error_component.as_str());
3634                    }
3635                }
3636                style_roots.extend(reachable.iter().map(String::as_str));
3637                let component_styles =
3638                    crate::route_handler::collect_component_styles(context.protocol, style_roots)?;
3639                write_webui_data_block(
3640                    context.writer,
3641                    &mut context.json_scratch,
3642                    WebUiBootstrap {
3643                        declaration_id: None,
3644                        enclosing_span_instance_id: None,
3645                        state: WebUiBootstrapState::Complete {
3646                            value: context.state,
3647                            selection: state_selection,
3648                        },
3649                        chain: &chain_json,
3650                        inventory: &inventory_hex,
3651                        nonce: context.nonce,
3652                        css_hrefs: &css_hrefs,
3653                        style_specs: &style_specs,
3654                        component_styles: &component_styles,
3655                        templates: payloads,
3656                    },
3657                )?;
3658
3659                // Let the active plugin emit any framework-specific executable
3660                // side channel. FAST plugins default to no-op; WebUI installs
3661                // templateFns. Client packages parse #webui-data lazily.
3662                if let Some(ref plugin) = context.plugin {
3663                    plugin.emit_bootstrap_extension(
3664                        BootstrapExtensionContext {
3665                            protocol: context.protocol,
3666                            components: &reachable,
3667                            payloads,
3668                            nonce: context.nonce,
3669                        },
3670                        context.writer,
3671                    )?;
3672                }
3673            }
3674
3675            // Per-render `body_inject` HTML — dev livereload script,
3676            // analytics, etc. supplied by the host via RenderOptions.
3677            // Inside the dedup block but outside the plugin-only
3678            // sub-block above, so it fires regardless of whether a
3679            // hydration plugin is active. Appears immediately before
3680            // `</body>`.
3681            if let Some(html) = context.body_inject {
3682                context.writer.write(html)?;
3683            }
3684
3685            // Reserved-state `bodyEnd` HTML, last at this boundary. Same
3686            // precedence as `head_end`: built-ins, `RenderOptions`, state.
3687            if let Some(html) = context.state_inject.body_end {
3688                context.writer.write(html)?;
3689            }
3690        }
3691
3692        // Structural signals are never state lookups. In particular, ordinary
3693        // rendering ignores boundary/root markers byte-for-byte, while authored
3694        // raw bindings with the same visible key remain ordinary state.
3695        Ok(())
3696    }
3697
3698    /// Process a plain (non-structural) signal by resolving its value and
3699    /// writing it to the response, HTML-encoded unless `signal.raw` is set.
3700    ///
3701    /// `owns_html_range` only controls whether the active plugin emits
3702    /// replaceable sibling markers around the value (`on_binding_start/end`);
3703    /// it never affects escaping. In particular, `signal.raw_text_context`
3704    /// (marker ownership inside `<style>`/`<script>`/etc.) is independent of
3705    /// `signal.raw` (escaping): a signal can be marker-free *and* HTML-encoded
3706    /// at the same time. See `WebUIFragment::raw_text_signal` for why an
3707    /// escaped binding inside an HTML raw-text element is an authoring
3708    /// footgun rather than something this function can safely correct.
3709    #[inline]
3710    fn process_state_signal(
3711        &self,
3712        signal: &webui_protocol::WebUIFragmentSignal,
3713        owns_html_range: bool,
3714        context: &mut WebUIProcessContext,
3715    ) -> Result<()> {
3716        if let Some(p) = &mut context.plugin {
3717            p.on_binding_start(&signal.value, owns_html_range, context.writer)?;
3718        }
3719
3720        if let Some(value) = resolve_value_from_sources(
3721            &signal.value,
3722            &context.loop_vars,
3723            context.visible_loop_scope,
3724            LocalValueSources {
3725                owned: &context.local_vars,
3726                borrowed: &context.local_borrowed_vars,
3727            },
3728            context.state,
3729        ) {
3730            self.write_signal_value(value.as_ref(), signal.raw, context.writer)?;
3731        }
3732
3733        if let Some(p) = &mut context.plugin {
3734            p.on_binding_end(&signal.value, owns_html_range, context.writer)?;
3735        }
3736        Ok(())
3737    }
3738
3739    /// Write a signal value directly to the writer, avoiding intermediate String allocation.
3740    /// For HTML-escaped output, writes the Cow from `encode_safe` directly.
3741    ///
3742    /// `raw` here is purely the authored escaping choice (`{{value}}` vs.
3743    /// `{{{value}}}`) and is applied uniformly regardless of surrounding HTML
3744    /// context: it does not know whether it is writing into a raw-text
3745    /// element (`<script>`, `<style>`, `<xmp>`, which never decode character
3746    /// references) or an RCDATA element (`<title>`, `<textarea>`, which do).
3747    fn write_signal_value(
3748        &self,
3749        value: &Value,
3750        raw: bool,
3751        writer: &mut dyn ResponseWriter,
3752    ) -> Result<()> {
3753        // Numbers, booleans, and null render the same escaped or not, and never
3754        // need a heap buffer to reach the writer.
3755        if write_plain_json_scalar(writer, value)? {
3756            return Ok(());
3757        }
3758        if raw {
3759            match value {
3760                Value::String(s) => writer.write(s),
3761                _ => writer.write(&value.to_string()),
3762            }
3763        } else {
3764            match value {
3765                Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
3766                _ => {
3767                    let s = value.to_string();
3768                    writer.write(&crate::html_encode::encode_safe(&s))
3769                }
3770            }
3771        }
3772    }
3773
3774    /// Process an if condition fragment.
3775    fn process_if(
3776        &self,
3777        if_cond: &webui_protocol::WebUIFragmentIf,
3778        target: Option<usize>,
3779        context: &mut WebUIProcessContext,
3780    ) -> Result<()> {
3781        let condition = if_cond
3782            .condition
3783            .as_ref()
3784            .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
3785        let condition_met = self.evaluate_condition(condition, context)?;
3786
3787        if let Some(p) = &mut context.plugin {
3788            p.on_if_start(&if_cond.fragment_id, context.writer)?;
3789        }
3790
3791        if condition_met {
3792            if let Some(p) = &mut context.plugin {
3793                p.push_scope();
3794            }
3795
3796            self.process_fragment_target(target, &if_cond.fragment_id, context)?;
3797
3798            if let Some(p) = &mut context.plugin {
3799                p.pop_scope();
3800            }
3801        }
3802
3803        if let Some(p) = &mut context.plugin {
3804            p.on_if_end(&if_cond.fragment_id, context.writer)?;
3805        }
3806
3807        Ok(())
3808    }
3809
3810    /// Process an attribute fragment by rendering the attribute name/value pair.
3811    ///
3812    /// `template_target` and `component_name` are prepared once per protocol by
3813    /// [`RenderFragmentIndex`], so neither the template fragment lookup nor the
3814    /// camelCase prop-name conversion is repeated per render.
3815    fn process_attribute<'protocol, 'state>(
3816        &self,
3817        attr: &'protocol webui_protocol::WebUIFragmentAttribute,
3818        template_target: Option<usize>,
3819        component_name: Option<&'protocol str>,
3820        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3821    ) -> Result<()> {
3822        // Initialize component attribute accumulator on attrStart. Clearing the
3823        // pooled map keeps its bucket capacity instead of allocating a fresh one.
3824        if attr.attr_start {
3825            context.component_attrs.clear();
3826            context.component_borrowed_attrs.clear();
3827            context.collecting_component_attrs = true;
3828        }
3829
3830        // Boolean attribute with condition tree
3831        if let Some(condition) = &attr.condition_tree {
3832            let condition_met = self.evaluate_condition(condition, context)?;
3833
3834            if context.collecting_component_attrs && !attr.attr_skip {
3835                let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3836                context.component_borrowed_attrs.remove(name);
3837                context
3838                    .component_attrs
3839                    .insert(name.to_owned(), Value::Bool(condition_met));
3840            }
3841
3842            if condition_met {
3843                context.writer.write(" ")?;
3844                context.writer.write(&attr.name)?;
3845            }
3846            return Ok(());
3847        }
3848
3849        // Template attribute (mixed static + dynamic)
3850        if !attr.template.is_empty() {
3851            let raw_value =
3852                self.render_template_attr_value(&attr.template, template_target, context)?;
3853            let escaped = crate::html_encode::encode_safe(&raw_value);
3854            write_attr(context.writer, &attr.name, &escaped)?;
3855
3856            if context.collecting_component_attrs && !attr.attr_skip {
3857                let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3858                context.component_borrowed_attrs.remove(name);
3859                context
3860                    .component_attrs
3861                    .insert(name.to_owned(), Value::String(raw_value));
3862            }
3863            return Ok(());
3864        }
3865
3866        // Simple attribute
3867        if attr.raw_value || !attr.value.is_empty() {
3868            if attr.raw_value {
3869                // Static attribute — value is the literal string
3870                write_attr(
3871                    context.writer,
3872                    &attr.name,
3873                    &crate::html_encode::encode_safe(&attr.value),
3874                )?;
3875                if context.collecting_component_attrs && !attr.attr_skip {
3876                    let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3877                    context.component_borrowed_attrs.remove(name);
3878                    context
3879                        .component_attrs
3880                        .insert(name.to_owned(), Value::String(attr.value.clone()));
3881                }
3882            } else if attr.complex {
3883                // Complex attribute — resolve value, don't render to HTML, store as state
3884                if context.collecting_component_attrs && !attr.attr_skip {
3885                    // Streaming starts borrowed too. The continuation VM
3886                    // materializes these values only when the target component
3887                    // can actually suspend; boundary-free components finish in
3888                    // this call and retain the ordinary zero-copy path.
3889                    let state_backed_value = resolve_state_backed_value(
3890                        &attr.value,
3891                        &context.loop_vars,
3892                        context.visible_loop_scope,
3893                        LocalValueSources {
3894                            owned: &context.local_vars,
3895                            borrowed: &context.local_borrowed_vars,
3896                        },
3897                        context.state,
3898                    );
3899                    if let Some(value) = state_backed_value {
3900                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3901                        remove_owned_component_attr(&mut context.component_attrs, name);
3902                        context.component_borrowed_attrs.insert(name, value);
3903                    } else if let Some(value) = self.resolve_value_owned(&attr.value, context) {
3904                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3905                        context.component_borrowed_attrs.remove(name);
3906                        context.component_attrs.insert(name.to_owned(), value);
3907                    }
3908                }
3909            } else {
3910                // Dynamic attribute — resolve and render
3911                // As above, a boundary-bearing continuation materializes a
3912                // borrowed component scope before it can escape this call.
3913                let state_backed_value = if context.collecting_component_attrs && !attr.attr_skip {
3914                    resolve_state_backed_value(
3915                        &attr.value,
3916                        &context.loop_vars,
3917                        context.visible_loop_scope,
3918                        LocalValueSources {
3919                            owned: &context.local_vars,
3920                            borrowed: &context.local_borrowed_vars,
3921                        },
3922                        context.state,
3923                    )
3924                } else {
3925                    None
3926                };
3927                // Reuse the immutable-state lookup for both HTML output and the
3928                // child scope instead of resolving every component attribute twice.
3929                let value = match state_backed_value {
3930                    Some(value) => Some(Cow::Borrowed(value)),
3931                    None => resolve_value_from_sources(
3932                        &attr.value,
3933                        &context.loop_vars,
3934                        context.visible_loop_scope,
3935                        LocalValueSources {
3936                            owned: &context.local_vars,
3937                            borrowed: &context.local_borrowed_vars,
3938                        },
3939                        context.state,
3940                    ),
3941                };
3942                match value.as_deref() {
3943                    Some(Value::String(s)) => {
3944                        write_attr(
3945                            context.writer,
3946                            &attr.name,
3947                            &crate::html_encode::encode_safe(s),
3948                        )?;
3949                    }
3950                    Some(Value::Null) | None => {}
3951                    Some(other) => {
3952                        let mut scalar = ScalarBuffer::new();
3953                        match format_plain_json_scalar(&mut scalar, other) {
3954                            Some(rendered) => {
3955                                write_attr(context.writer, &attr.name, rendered)?;
3956                            }
3957                            None => {
3958                                let s = other.to_string();
3959                                write_attr(
3960                                    context.writer,
3961                                    &attr.name,
3962                                    &crate::html_encode::encode_safe(&s),
3963                                )?;
3964                            }
3965                        }
3966                    }
3967                }
3968
3969                if context.collecting_component_attrs && !attr.attr_skip {
3970                    if let Some(borrowed) = state_backed_value {
3971                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3972                        remove_owned_component_attr(&mut context.component_attrs, name);
3973                        context.component_borrowed_attrs.insert(name, borrowed);
3974                    } else {
3975                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3976                        context.component_borrowed_attrs.remove(name);
3977                        context.component_attrs.insert(
3978                            name.to_owned(),
3979                            value
3980                                .map(Cow::into_owned)
3981                                .unwrap_or(Value::String(String::new())),
3982                        );
3983                    }
3984                }
3985            }
3986        }
3987
3988        Ok(())
3989    }
3990
3991    /// Render a template attribute's fragments into a raw (unescaped) string.
3992    fn render_template_attr_value(
3993        &self,
3994        template_id: &str,
3995        target: Option<usize>,
3996        context: &WebUIProcessContext,
3997    ) -> Result<String> {
3998        let Some(index) = target.or_else(|| context.render_fragments.index(template_id)) else {
3999            return Err(HandlerError::MissingFragment(template_id.to_string()));
4000        };
4001        let fragments = context
4002            .render_fragments
4003            .list(index)
4004            .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
4005        let mut raw_value = String::with_capacity(template_attr_capacity(fragments.fragments));
4006        for frag in fragments.fragments {
4007            match frag.fragment.as_ref() {
4008                Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
4009                Some(Fragment::Signal(signal)) => {
4010                    if let Some(value) = resolve_value_from_sources(
4011                        &signal.value,
4012                        &context.loop_vars,
4013                        context.visible_loop_scope,
4014                        LocalValueSources {
4015                            owned: &context.local_vars,
4016                            borrowed: &context.local_borrowed_vars,
4017                        },
4018                        context.state,
4019                    ) {
4020                        match value.as_ref() {
4021                            Value::String(s) => raw_value.push_str(s),
4022                            other => {
4023                                let mut scalar = ScalarBuffer::new();
4024                                match format_plain_json_scalar(&mut scalar, other) {
4025                                    Some(rendered) => raw_value.push_str(rendered),
4026                                    None => raw_value.push_str(&other.to_string()),
4027                                }
4028                            }
4029                        }
4030                    }
4031                }
4032                _ => {}
4033            }
4034        }
4035        Ok(raw_value)
4036    }
4037
4038    /// Render the UI based on the protocol and state.
4039    pub fn render<'a>(
4040        &self,
4041        protocol: &'a Protocol,
4042        state: &'a Value,
4043        options: &RenderOptions<'a>,
4044        writer: &'a mut dyn ResponseWriter,
4045    ) -> Result<()> {
4046        protocol.ensure_style_metadata()?;
4047        let document = protocol.protocol();
4048        let render_fragments = protocol.render_fragments().resolve(document);
4049        let Some(entry_index) = render_fragments.index(options.entry_id) else {
4050            return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
4051        };
4052        let entry = render_fragments
4053            .list(entry_index)
4054            .ok_or_else(|| HandlerError::MissingFragment(options.entry_id.to_string()))?;
4055        let entry_owns_css_tree = Self::component_owns_css_tree(options.entry_id, document);
4056        let has_document_head_boundary = !entry_owns_css_tree
4057            && !document.style_closures.is_empty()
4058            && entry.fragments.iter().any(|fragment| {
4059                matches!(
4060                    fragment.fragment.as_ref(),
4061                    Some(Fragment::Signal(signal))
4062                        if structural_signal_value(signal) == Some("head_end")
4063                )
4064            });
4065        let doctype_split = (!entry_owns_css_tree
4066            && !has_document_head_boundary
4067            && !document.style_closures.is_empty())
4068        .then(|| {
4069            let Some(Fragment::Raw(raw)) = entry
4070                .fragments
4071                .first()
4072                .and_then(|fragment| fragment.fragment.as_ref())
4073            else {
4074                return None;
4075            };
4076            doctype_prefix_end(&raw.value).map(|end| (raw.value.as_str(), end))
4077        })
4078        .flatten();
4079        let component_asset_style_manifest = protocol.component_asset_style_manifest()?;
4080        let mut context = WebUIProcessContext {
4081            protocol: document,
4082            render_fragments,
4083            component_asset_style_manifest,
4084            component_asset_style_links: protocol.component_asset_style_links(),
4085            state,
4086            writer,
4087            local_vars: ScopeMap::default(),
4088            local_borrowed_vars: BorrowedScope::default(),
4089            loop_vars: Vec::new(),
4090            visible_loop_scope: VisibleLoopScope::EMPTY,
4091            component_attrs: ScopeMap::default(),
4092            component_borrowed_attrs: BorrowedScope::default(),
4093            collecting_component_attrs: false,
4094            request_path: options.request_path,
4095            route_base: Cow::Borrowed("/"),
4096            rendered_components: HashSet::new(),
4097            plugin: self.plugin_factory.map(|f| f()),
4098            route_children: Cow::Borrowed(&[]),
4099            entry_id: options.entry_id,
4100            // Same defensive normalisation as `handle()`. See the
4101            // doc-comment there for the CSP-outage rationale.
4102            nonce: options.nonce.filter(|s| !s.is_empty()),
4103            head_inject: options.head_inject.filter(|s| !s.is_empty()),
4104            body_inject: options.body_inject.filter(|s| !s.is_empty()),
4105            state_inject: StateInject::resolve(state),
4106            head_end_emitted: false,
4107            body_start_emitted: false,
4108            component_asset_styles_emitted: false,
4109            component_index: protocol.component_index(),
4110            style_resource_index: protocol.style_resource_index(),
4111            style_resources_requiring_escape: protocol.style_resources_requiring_escape(),
4112            style_chunk_index: protocol.protocol().style_chunk_index(),
4113            css_strategy: protocol.css_strategy(),
4114            body_end_emitted: false,
4115            route_index: protocol.route_index(),
4116            route_chain_index: 0,
4117            route_chain: None,
4118            route_document_style_targets: Vec::new(),
4119            reachable_components: None,
4120            streaming: None,
4121            json_scratch: Vec::new(),
4122            scope_pool: Vec::new(),
4123            document_style_resources: HashSet::new(),
4124            shadow_style_roots: Vec::new(),
4125            borrowed_scope_pool: Vec::new(),
4126        };
4127
4128        if entry_owns_css_tree {
4129            Self::push_shadow_style_root(options.entry_id, &mut context)?;
4130        }
4131
4132        let render_result = if let Some((first_raw, split)) = doctype_split {
4133            context.writer.write(&first_raw[..split])?;
4134            self.emit_component_style_closure(
4135                options.entry_id,
4136                StyleClosureInstall::Static,
4137                &mut context,
4138            )?;
4139            self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
4140            context.writer.write(&first_raw[split..])?;
4141            self.process_fragment_from(entry, 1, &mut context)
4142        } else {
4143            if !entry_owns_css_tree
4144                && !has_document_head_boundary
4145                && !document.style_closures.is_empty()
4146            {
4147                self.emit_component_style_closure(
4148                    options.entry_id,
4149                    StyleClosureInstall::Static,
4150                    &mut context,
4151                )?;
4152                self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
4153            }
4154            self.process_fragment(entry, &mut context)
4155        };
4156
4157        if entry_owns_css_tree {
4158            Self::pop_shadow_style_root(options.entry_id, &mut context)?;
4159        }
4160        render_result?;
4161        writer.end()?;
4162
4163        Ok(())
4164    }
4165}
4166
4167impl Default for WebUIHandler {
4168    fn default() -> Self {
4169        Self::new()
4170    }
4171}
4172
4173/// Bytes reserved for each signal when pre-sizing a template attribute value.
4174/// Signals in attribute templates are typically ids, numbers, or path segments.
4175const TEMPLATE_SIGNAL_CAPACITY_HINT: usize = 32;
4176
4177/// Starting capacity for a rendered template attribute value: the exact static
4178/// text plus a per-signal estimate, so typical values fill one allocation
4179/// instead of regrowing on every append.
4180fn template_attr_capacity(fragments: &[WebUIFragment]) -> usize {
4181    fragments
4182        .iter()
4183        .map(|frag| match frag.fragment.as_ref() {
4184            Some(Fragment::Raw(raw)) => raw.value.len(),
4185            Some(Fragment::Signal(_)) => TEMPLATE_SIGNAL_CAPACITY_HINT,
4186            _ => 0,
4187        })
4188        .sum()
4189}
4190
4191/// Write ` name="value"` to the writer without allocating a format string.
4192fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
4193    writer.write_attribute(name, value)
4194}
4195
4196#[cfg(test)]
4197fn handle(
4198    protocol: &WebUIProtocol,
4199    state: &Value,
4200    options: &RenderOptions<'_>,
4201    writer: &mut dyn ResponseWriter,
4202) -> Result<()> {
4203    let handler = WebUIHandler::new();
4204    handler.handle(protocol, state, options, writer)
4205}
4206
4207#[cfg(test)]
4208mod scalar_buffer_tests {
4209    use super::*;
4210
4211    /// Every scalar the stack formatter claims must serialize byte-identically
4212    /// to the `Value::to_string` path it replaced.
4213    #[test]
4214    fn plain_scalars_match_value_to_string() {
4215        let cases = [
4216            Value::Null,
4217            Value::Bool(true),
4218            Value::Bool(false),
4219            serde_json::json!(0),
4220            serde_json::json!(-1),
4221            serde_json::json!(42),
4222            serde_json::json!(i64::MIN),
4223            serde_json::json!(i64::MAX),
4224            serde_json::json!(u64::MAX),
4225            serde_json::json!(0.5),
4226            serde_json::json!(-0.0_f64),
4227            serde_json::json!(1.7976931348623157e308_f64),
4228            serde_json::json!(-1.7976931348623157e308_f64),
4229            serde_json::json!(f64::MIN_POSITIVE),
4230        ];
4231        for value in cases {
4232            let mut buffer = ScalarBuffer::new();
4233            let rendered = format_plain_json_scalar(&mut buffer, &value);
4234            assert_eq!(
4235                rendered,
4236                Some(value.to_string().as_str()),
4237                "scalar rendering diverged for {value:?}"
4238            );
4239        }
4240    }
4241
4242    /// Strings, arrays, and objects must fall through to the caller's escaping
4243    /// fallback rather than being emitted unescaped.
4244    #[test]
4245    fn non_plain_values_are_declined() {
4246        let cases = [
4247            serde_json::json!("<script>"),
4248            serde_json::json!([1, 2]),
4249            serde_json::json!({ "a": 1 }),
4250        ];
4251        for value in cases {
4252            let mut buffer = ScalarBuffer::new();
4253            assert_eq!(
4254                format_plain_json_scalar(&mut buffer, &value),
4255                None,
4256                "expected fallback for {value:?}"
4257            );
4258        }
4259    }
4260
4261    /// Overflow must be reported, never silently truncated into bad output.
4262    #[test]
4263    fn overflow_is_reported_instead_of_truncating() {
4264        let mut buffer = ScalarBuffer::new();
4265        assert!(write!(&mut buffer, "{}", "x".repeat(49)).is_err());
4266    }
4267}
4268
4269#[cfg(test)]
4270mod tests {
4271    use super::*;
4272    use crate::streaming::STREAMING_MARKER;
4273    use std::cell::RefCell;
4274    use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
4275    use webui_protocol::{
4276        web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
4277        WebUIFragmentAttribute, WebUiFragmentRoute,
4278    };
4279    use webui_test_utils::test_json;
4280
4281    fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
4282        WebUIFragment::signal(
4283            format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
4284            true,
4285        )
4286    }
4287
4288    #[test]
4289    fn route_descent_borrows_protocol_children() {
4290        let routes = vec![WebUiFragmentRoute {
4291            children: vec![WebUiFragmentRoute {
4292                fragment_id: "child".to_string(),
4293                ..Default::default()
4294            }],
4295            ..Default::default()
4296        }];
4297        let mut level = RouteChildren::Borrowed(&routes);
4298
4299        let descended = descend_into(&mut level, 0);
4300
4301        let Cow::Borrowed(children) = descended else {
4302            panic!("protocol-owned route children should remain borrowed");
4303        };
4304        assert!(std::ptr::eq(children, routes[0].children.as_slice()));
4305    }
4306
4307    #[test]
4308    fn route_descent_moves_parked_children() {
4309        let mut level = RouteChildren::Owned(vec![WebUiFragmentRoute {
4310            children: vec![WebUiFragmentRoute {
4311                fragment_id: "child".to_string(),
4312                ..Default::default()
4313            }],
4314            ..Default::default()
4315        }]);
4316
4317        let descended = descend_into(&mut level, 0);
4318
4319        let Cow::Owned(children) = descended else {
4320            panic!("parked route children must not borrow session-owned storage");
4321        };
4322        assert_eq!(children[0].fragment_id, "child");
4323        let Cow::Owned(source) = level else {
4324            panic!("the parked source level must stay owned");
4325        };
4326        assert!(
4327            source[0].children.is_empty(),
4328            "owned route children must move out instead of being cloned"
4329        );
4330    }
4331
4332    #[test]
4333    fn borrowed_collection_reuses_state_array() {
4334        let state = test_json!({
4335            "items": [
4336                {"name": "first"},
4337                {"name": "second"}
4338            ]
4339        });
4340        let local_vars = ScopeMap::default();
4341        let local_borrowed_vars = BorrowedScope::default();
4342        let loop_vars = Vec::new();
4343        let items = resolve_borrowed_collection(
4344            "items",
4345            &loop_vars,
4346            VisibleLoopScope::EMPTY,
4347            LocalValueSources {
4348                owned: &local_vars,
4349                borrowed: &local_borrowed_vars,
4350            },
4351            &state,
4352        )
4353        .unwrap_or_else(|| panic!("state array should be borrowed"));
4354        let state_items = state["items"]
4355            .as_array()
4356            .unwrap_or_else(|| panic!("fixture items should be an array"));
4357        assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4358    }
4359
4360    #[test]
4361    fn owned_local_collection_keeps_precedence() {
4362        let state = test_json!({"items": [{"name": "global"}]});
4363        let local_vars =
4364            ScopeMap::from_iter([("items".to_string(), test_json!([{"name": "local"}]))]);
4365        let local_borrowed_vars = BorrowedScope::default();
4366        assert!(resolve_borrowed_collection(
4367            "items",
4368            &[],
4369            VisibleLoopScope::EMPTY,
4370            LocalValueSources {
4371                owned: &local_vars,
4372                borrowed: &local_borrowed_vars,
4373            },
4374            &state,
4375        )
4376        .is_none());
4377    }
4378
4379    #[test]
4380    fn borrowed_component_collection_reuses_state_array() {
4381        let state = test_json!({"teams": [{"contacts": [{"name": "Avery"}]}]});
4382        let contacts = &state["teams"][0]["contacts"];
4383        let mut local_borrowed_vars = BorrowedScope::default();
4384        local_borrowed_vars.insert("contacts", contacts);
4385        let local_vars = ScopeMap::default();
4386        let items = resolve_borrowed_collection(
4387            "contacts",
4388            &[],
4389            VisibleLoopScope::EMPTY,
4390            LocalValueSources {
4391                owned: &local_vars,
4392                borrowed: &local_borrowed_vars,
4393            },
4394            &state,
4395        )
4396        .unwrap_or_else(|| panic!("component collection should stay borrowed"));
4397        let state_items = contacts
4398            .as_array()
4399            .unwrap_or_else(|| panic!("fixture contacts should be an array"));
4400        assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4401    }
4402
4403    #[test]
4404    fn nested_borrowed_collection_reuses_child_array() {
4405        let state = test_json!({
4406            "items": [{
4407                "children": [
4408                    {"name": "first"},
4409                    {"name": "second"}
4410                ]
4411            }]
4412        });
4413        let item = &state["items"][0];
4414        let loop_vars = vec![LoopBinding {
4415            name: "item",
4416            value: item,
4417        }];
4418        let local_vars = ScopeMap::default();
4419        let local_borrowed_vars = BorrowedScope::default();
4420        let children = resolve_borrowed_collection(
4421            "item.children",
4422            &loop_vars,
4423            VisibleLoopScope { start: 0, end: 1 },
4424            LocalValueSources {
4425                owned: &local_vars,
4426                borrowed: &local_borrowed_vars,
4427            },
4428            &state,
4429        )
4430        .unwrap_or_else(|| panic!("nested state array should be borrowed"));
4431        let state_children = item["children"]
4432            .as_array()
4433            .unwrap_or_else(|| panic!("fixture children should be an array"));
4434        assert!(std::ptr::eq(children.as_ptr(), state_children.as_ptr()));
4435    }
4436
4437    #[test]
4438    fn borrowed_resolver_preserves_loop_precedence_and_component_isolation() {
4439        let state = test_json!({
4440            "item": {"name": "global", "fallback": "global fallback"},
4441            "outer": {"name": "outer"},
4442            "inner": {"name": "inner"}
4443        });
4444        let loop_vars = vec![
4445            LoopBinding {
4446                name: "item",
4447                value: &state["outer"],
4448            },
4449            LoopBinding {
4450                name: "item",
4451                value: &state["inner"],
4452            },
4453        ];
4454        let local_vars = ScopeMap::default();
4455        let local_borrowed_vars = BorrowedScope::default();
4456        let sources = LocalValueSources {
4457            owned: &local_vars,
4458            borrowed: &local_borrowed_vars,
4459        };
4460
4461        assert_eq!(
4462            resolve_value_from_sources(
4463                "item.name",
4464                &loop_vars,
4465                VisibleLoopScope { start: 0, end: 2 },
4466                sources,
4467                &state,
4468            )
4469            .as_deref()
4470            .and_then(Value::as_str),
4471            Some("inner")
4472        );
4473        assert_eq!(
4474            resolve_value_from_sources(
4475                "item.fallback",
4476                &loop_vars,
4477                VisibleLoopScope { start: 0, end: 2 },
4478                sources,
4479                &state,
4480            )
4481            .as_deref()
4482            .and_then(Value::as_str),
4483            Some("global fallback")
4484        );
4485        assert_eq!(
4486            resolve_value_from_sources(
4487                "item.name",
4488                &loop_vars,
4489                VisibleLoopScope { start: 2, end: 2 },
4490                sources,
4491                &state,
4492            )
4493            .as_deref()
4494            .and_then(Value::as_str),
4495            Some("global")
4496        );
4497    }
4498
4499    #[test]
4500    fn state_backed_resolver_matches_general_borrowed_resolution() {
4501        let state = test_json!({
4502            "global": {"name": "global"},
4503            "inner": {"fallback": "global fallback"},
4504            "borrowed": {"name": "borrowed"},
4505            "items": [1, 2, 3]
4506        });
4507        let loop_values = test_json!({
4508            "outer": {"name": "outer"},
4509            "inner": {"name": "inner"}
4510        });
4511        let loop_vars = vec![
4512            LoopBinding {
4513                name: "outer",
4514                value: &loop_values["outer"],
4515            },
4516            LoopBinding {
4517                name: "inner",
4518                value: &loop_values["inner"],
4519            },
4520        ];
4521        let mut local_borrowed_vars = BorrowedScope::default();
4522        local_borrowed_vars.insert("borrowed", &state["borrowed"]);
4523        let local_vars =
4524            ScopeMap::from_iter([("owned".to_string(), test_json!({"name": "owned"}))]);
4525        let sources = LocalValueSources {
4526            owned: &local_vars,
4527            borrowed: &local_borrowed_vars,
4528        };
4529        let scope = VisibleLoopScope { start: 0, end: 2 };
4530
4531        for path in [
4532            "global.name",
4533            "inner.name",
4534            "inner.fallback",
4535            "outer.name",
4536            "borrowed.name",
4537        ] {
4538            let state_backed = resolve_state_backed_value(path, &loop_vars, scope, sources, &state)
4539                .unwrap_or_else(|| panic!("{path} should resolve from immutable state"));
4540            let resolved = resolve_value_from_sources(path, &loop_vars, scope, sources, &state)
4541                .unwrap_or_else(|| panic!("{path} should resolve generally"));
4542            let Cow::Borrowed(resolved) = resolved else {
4543                panic!("{path} should remain borrowed");
4544            };
4545            assert!(
4546                std::ptr::eq(state_backed, resolved),
4547                "{path} resolved from different sources"
4548            );
4549        }
4550
4551        assert!(
4552            resolve_state_backed_value("owned.name", &loop_vars, scope, sources, &state).is_none(),
4553            "owned local values must use the general resolver"
4554        );
4555        assert!(
4556            resolve_state_backed_value("items.length", &loop_vars, scope, sources, &state)
4557                .is_none(),
4558            "synthetic values must use the general resolver"
4559        );
4560    }
4561
4562    #[test]
4563    fn borrowed_scope_preserves_inline_overflow_and_replacement_semantics() {
4564        let values = [
4565            Value::String("a".to_string()),
4566            Value::String("b".to_string()),
4567            Value::String("c".to_string()),
4568            Value::String("d".to_string()),
4569            Value::String("e".to_string()),
4570            Value::String("f".to_string()),
4571            Value::String("replacement".to_string()),
4572        ];
4573        let mut scope = BorrowedScope::default();
4574        for (index, name) in ["a", "b", "c", "d", "e", "f"].iter().enumerate() {
4575            assert!(scope.insert(name, &values[index]).is_none());
4576        }
4577        assert_eq!(scope.inline_len, INLINE_SCOPE_SLOTS);
4578        assert_eq!(scope.overflow.len(), 2);
4579        assert_eq!(scope.get("a"), Some(&values[0]));
4580        assert_eq!(scope.get("f"), Some(&values[5]));
4581        assert_eq!(scope.insert("a", &values[6]), Some(&values[0]));
4582        assert_eq!(scope.get("a"), Some(&values[6]));
4583        assert_eq!(scope.remove("b"), Some(&values[1]));
4584        assert!(scope.get("b").is_none());
4585        assert_eq!(scope.get("f"), Some(&values[5]));
4586        scope.clear();
4587        assert!(scope.get("a").is_none());
4588        assert!(scope.overflow.is_empty());
4589    }
4590
4591    // A simple test writer implementation
4592    struct TestWriter {
4593        content: RefCell<String>,
4594        ended: RefCell<bool>,
4595    }
4596
4597    impl TestWriter {
4598        fn new() -> Self {
4599            Self {
4600                content: RefCell::new(String::new()),
4601                ended: RefCell::new(false),
4602            }
4603        }
4604
4605        fn get_content(&self) -> String {
4606            self.content.borrow().clone()
4607        }
4608
4609        fn is_ended(&self) -> bool {
4610            *self.ended.borrow()
4611        }
4612    }
4613
4614    impl ResponseWriter for TestWriter {
4615        fn write(&mut self, content: &str) -> Result<()> {
4616            self.content.borrow_mut().push_str(content);
4617            Ok(())
4618        }
4619
4620        fn end(&mut self) -> Result<()> {
4621            *self.ended.borrow_mut() = true;
4622            Ok(())
4623        }
4624    }
4625
4626    #[test]
4627    fn generated_string_writer_methods_preserve_exact_attribute_output() {
4628        struct AttributeWriter {
4629            output: String,
4630        }
4631
4632        impl ResponseWriter for AttributeWriter {
4633            fn write(&mut self, content: &str) -> Result<()> {
4634                self.output.push_str(content);
4635                Ok(())
4636            }
4637
4638            crate::string_response_writer_methods!(output);
4639
4640            fn end(&mut self) -> Result<()> {
4641                Ok(())
4642            }
4643        }
4644
4645        let mut writer = AttributeWriter {
4646            output: String::new(),
4647        };
4648        write_attr(&mut writer, "data-id", "42")
4649            .unwrap_or_else(|error| panic!("attribute write failed: {error}"));
4650        writer
4651            .write_boolean_attribute("disabled")
4652            .unwrap_or_else(|error| panic!("boolean attribute write failed: {error}"));
4653        assert_eq!(writer.output, " data-id=\"42\" disabled");
4654    }
4655
4656    #[test]
4657    fn prepared_graph_resolves_targets_and_canonical_component_prop_names() {
4658        let mut fragments = HashMap::new();
4659        fragments.insert(
4660            "index.html".to_string(),
4661            FragmentList {
4662                fragments: vec![
4663                    WebUIFragment {
4664                        fragment: Some(Fragment::Attribute(WebUIFragmentAttribute {
4665                            name: ":data-title".to_string(),
4666                            value: "source".to_string(),
4667                            complex: true,
4668                            attr_start: true,
4669                            ..Default::default()
4670                        })),
4671                    },
4672                    WebUIFragment::component("prepared-child"),
4673                ],
4674                contains_boundary: false,
4675            },
4676        );
4677        fragments.insert(
4678            "prepared-child".to_string(),
4679            FragmentList {
4680                fragments: vec![WebUIFragment::signal("dataTitle", false)],
4681                contains_boundary: false,
4682            },
4683        );
4684        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4685        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4686        let entry = resolved
4687            .list_by_id("index.html")
4688            .unwrap_or_else(|| panic!("entry render plan should exist"));
4689        let child_index = protocol
4690            .render_fragments()
4691            .index("prepared-child")
4692            .unwrap_or_else(|| panic!("component target should be indexed"));
4693        assert_eq!(entry.component_attr_name(0), Some("dataTitle"));
4694        assert_eq!(entry.target(1), Some(child_index));
4695        assert!(!entry.has_routes);
4696        let source_entry = protocol
4697            .protocol()
4698            .fragments
4699            .get("index.html")
4700            .unwrap_or_else(|| panic!("source entry should exist"));
4701        assert!(std::ptr::eq(
4702            entry.fragments.as_ptr(),
4703            source_entry.fragments.as_ptr()
4704        ));
4705
4706        let state = test_json!({"source": "Prepared"});
4707        let options = RenderOptions::new("index.html", "/");
4708        for handler in [
4709            WebUIHandler::new(),
4710            WebUIHandler::with_plugin(|| {
4711                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
4712            }),
4713        ] {
4714            let mut writer = TestWriter::new();
4715            assert!(handler
4716                .render(&protocol, &state, &options, &mut writer)
4717                .is_ok());
4718            assert!(writer.get_content().contains("Prepared"));
4719        }
4720    }
4721
4722    #[test]
4723    fn prepared_graph_records_route_presence_per_fragment_list() {
4724        let mut fragments = HashMap::new();
4725        fragments.insert(
4726            "index.html".to_string(),
4727            FragmentList {
4728                fragments: vec![WebUIFragment::route("/", "dash-page")],
4729                contains_boundary: false,
4730            },
4731        );
4732        fragments.insert(
4733            "dash-page".to_string(),
4734            FragmentList {
4735                fragments: vec![WebUIFragment::raw("dashboard")],
4736                contains_boundary: false,
4737            },
4738        );
4739        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4740        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4741        let entry = resolved
4742            .list_by_id("index.html")
4743            .unwrap_or_else(|| panic!("entry render plan should exist"));
4744        let page = resolved
4745            .list_by_id("dash-page")
4746            .unwrap_or_else(|| panic!("page render plan should exist"));
4747        // Only lists that actually contain a route pay for the sibling scan.
4748        assert!(entry.has_routes);
4749        assert!(!page.has_routes);
4750    }
4751
4752    #[test]
4753    fn prepared_prop_names_match_attribute_to_camel_exactly() {
4754        // Preparation writes the camelCase conversion straight into the shared
4755        // arena instead of calling `attribute_to_camel`, which allocates. The two
4756        // must stay byte-identical, including the irregular lookup-table entries.
4757        let names = [
4758            "data-title",
4759            ":data-title",
4760            "aria-labelledby",
4761            "aria-valuemax",
4762            "readonly",
4763            "tabindex",
4764            "class",
4765            "for",
4766            "maxlength",
4767            "novalidate",
4768            "already-camel",
4769            "trailing-",
4770            "-leading",
4771            "multi-part-attribute-name",
4772            "single",
4773            "",
4774        ];
4775        for name in names {
4776            let stripped = name.strip_prefix(':').unwrap_or(name);
4777            assert_eq!(
4778                component_attr_name(name),
4779                webui_protocol::attrs::attribute_to_camel(stripped),
4780                "prepared name diverged for `{name}`"
4781            );
4782        }
4783    }
4784
4785    #[test]
4786    fn prepared_component_prop_names_match_per_branch_stripping() {
4787        // The render path strips a leading `:` twice on the complex-value branch
4788        // and once elsewhere. Preparation must reproduce that per branch or a
4789        // `::`-prefixed binding would change the emitted component prop name.
4790        let complex = WebUIFragmentAttribute {
4791            name: "::data-title".to_string(),
4792            value: "source".to_string(),
4793            complex: true,
4794            ..Default::default()
4795        };
4796        assert_eq!(component_attr_source(&complex), ":data-title");
4797        assert_eq!(
4798            component_attr_name(component_attr_source(&complex)),
4799            "dataTitle"
4800        );
4801
4802        // `raw_value` takes the static branch, which strips only once.
4803        let raw = WebUIFragmentAttribute {
4804            raw_value: true,
4805            ..complex.clone()
4806        };
4807        assert_eq!(component_attr_source(&raw), "::data-title");
4808
4809        // A template attribute returns before the complex branch is reached.
4810        let templated = WebUIFragmentAttribute {
4811            template: "tpl".to_string(),
4812            ..complex.clone()
4813        };
4814        assert_eq!(component_attr_source(&templated), "::data-title");
4815
4816        // An empty value never reaches the complex branch either.
4817        let empty_value = WebUIFragmentAttribute {
4818            value: String::new(),
4819            ..complex.clone()
4820        };
4821        assert_eq!(component_attr_source(&empty_value), "::data-title");
4822
4823        // Ordinary single-colon bindings are unaffected on every branch.
4824        let ordinary = WebUIFragmentAttribute {
4825            name: ":data-title".to_string(),
4826            ..complex
4827        };
4828        assert_eq!(component_attr_source(&ordinary), "data-title");
4829        assert_eq!(
4830            component_attr_name(component_attr_source(&ordinary)),
4831            "dataTitle"
4832        );
4833    }
4834
4835    #[test]
4836    fn render_slots_match_continuation_slots() {
4837        // The streaming VM reads prepared metadata with the continuation slot
4838        // directly. That is only sound while both numberings index the same
4839        // sorted fragment IDs, so pin the invariant here rather than paying for
4840        // an ID lookup per streamed fragment frame.
4841        let mut fragments = HashMap::new();
4842        for id in [
4843            "index.html",
4844            "zeta-last",
4845            "alpha-first",
4846            "middle-one",
4847            "middle-two",
4848        ] {
4849            fragments.insert(
4850                id.to_string(),
4851                FragmentList {
4852                    fragments: vec![WebUIFragment::raw(id)],
4853                    contains_boundary: false,
4854                },
4855            );
4856        }
4857        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4858        let index = protocol.render_fragments();
4859        for slot in 0..u32::try_from(protocol.protocol().fragments.len())
4860            .unwrap_or_else(|_| panic!("fragment count fits in u32"))
4861        {
4862            let id = protocol
4863                .fragment_id(slot)
4864                .unwrap_or_else(|| panic!("slot {slot} should resolve to an ID"));
4865            assert_eq!(
4866                index.index(id),
4867                Some(slot as usize),
4868                "render slot must equal continuation slot for `{id}`"
4869            );
4870            assert_eq!(protocol.fragment_slot(id), Some(slot));
4871        }
4872    }
4873
4874    #[test]
4875    fn prepared_graph_reports_missing_fragments_without_panicking() {
4876        let mut fragments = HashMap::new();
4877        fragments.insert(
4878            "index.html".to_string(),
4879            FragmentList {
4880                fragments: vec![WebUIFragment::component("absent-child")],
4881                contains_boundary: false,
4882            },
4883        );
4884        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4885        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4886        let entry = resolved
4887            .list_by_id("index.html")
4888            .unwrap_or_else(|| panic!("entry render plan should exist"));
4889        // An unresolvable reference keeps a `None` target and must not index
4890        // out of range or silently render an unrelated fragment list.
4891        assert_eq!(entry.target(0), None);
4892        assert!(resolved.list_by_id("absent-child").is_none());
4893        assert!(resolved.list(usize::MAX).is_none());
4894
4895        let state = test_json!({});
4896        let options = RenderOptions::new("index.html", "/");
4897        let mut writer = TestWriter::new();
4898        match WebUIHandler::new().render(&protocol, &state, &options, &mut writer) {
4899            Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-child"),
4900            other => panic!("expected a missing fragment error, got {other:?}"),
4901        }
4902
4903        let mut writer = TestWriter::new();
4904        match WebUIHandler::new().render(
4905            &protocol,
4906            &state,
4907            &RenderOptions::new("absent-entry", "/"),
4908            &mut writer,
4909        ) {
4910            Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-entry"),
4911            other => panic!("expected a missing entry error, got {other:?}"),
4912        }
4913        // Bindings check the entry before sizing an output buffer, so the
4914        // check must agree with render's own entry lookup.
4915        assert!(protocol.has_fragment("index.html"));
4916        assert!(!protocol.has_fragment("absent-entry"));
4917    }
4918
4919    #[test]
4920    fn prepared_graph_lazily_borrows_large_protocol_fragment_lists() {
4921        let list_count = INLINE_RENDER_FRAGMENT_LISTS * 2 + 1;
4922        let mut fragments = HashMap::with_capacity(list_count);
4923        for index in 0..list_count {
4924            fragments.insert(
4925                format!("fragment-{index}"),
4926                FragmentList {
4927                    fragments: vec![WebUIFragment::raw("content")],
4928                    contains_boundary: false,
4929                },
4930            );
4931        }
4932        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4933        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4934        // Nothing is materialized until a slot is actually rendered.
4935        assert!(resolved.cache[0].get().is_none());
4936        assert!(resolved.spilled.get().is_none());
4937        assert!(resolved.list(0).is_some());
4938        assert!(resolved.cache[0].get().is_some());
4939        // Rendering only inline slots never allocates the spill region.
4940        assert!(resolved.spilled.get().is_none());
4941        // Slots past the inline cache spill once, then stay memoized so a deep
4942        // descent never re-hashes the fragment ID.
4943        assert!(resolved.list(INLINE_RENDER_FRAGMENT_LISTS).is_some());
4944        let spilled = resolved
4945            .spilled
4946            .get()
4947            .expect("a slot past the inline cache should allocate the spill region");
4948        assert_eq!(spilled.len(), list_count - INLINE_RENDER_FRAGMENT_LISTS);
4949        assert!(spilled[0].get().is_some());
4950        assert!(resolved.list(list_count - 1).is_some());
4951
4952        for (id, source) in &protocol.protocol().fragments {
4953            let prepared = resolved
4954                .list_by_id(id)
4955                .unwrap_or_else(|| panic!("render plan should contain {id}"));
4956            // Borrowed, never duplicated: the prepared list points at protocol storage.
4957            assert!(std::ptr::eq(
4958                prepared.fragments.as_ptr(),
4959                source.fragments.as_ptr()
4960            ));
4961        }
4962    }
4963
4964    #[test]
4965    fn test_handle_raw() {
4966        // Create a simple protocol
4967        let mut fragments = HashMap::new();
4968        fragments.insert(
4969            "index.html".to_string(),
4970            FragmentList {
4971                fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
4972                contains_boundary: false,
4973            },
4974        );
4975
4976        let protocol = WebUIProtocol::new(fragments);
4977        let state = test_json!({});
4978
4979        // Create a test writer
4980        let mut writer = TestWriter::new();
4981
4982        // Handle the protocol
4983        assert!(
4984            handle(
4985                &protocol,
4986                &state,
4987                &RenderOptions::new("index.html", "/"),
4988                &mut writer
4989            )
4990            .is_ok(),
4991            "Failed to handle raw protocol"
4992        );
4993
4994        // Check the output
4995        assert_eq!(writer.get_content(), "Hello, WebUI!");
4996        assert!(writer.is_ended());
4997    }
4998
4999    #[test]
5000    fn test_handle_signal() {
5001        // Create a protocol with a signal
5002        let mut fragments = HashMap::new();
5003        fragments.insert(
5004            "index.html".to_string(),
5005            FragmentList {
5006                fragments: vec![
5007                    WebUIFragment::raw("Hello, "),
5008                    WebUIFragment::signal("name", false),
5009                    WebUIFragment::raw("!"),
5010                ],
5011                contains_boundary: false,
5012            },
5013        );
5014
5015        let protocol = WebUIProtocol::new(fragments);
5016        let state = test_json!({"name": "WebUI"});
5017
5018        // Create a test writer
5019        let mut writer = TestWriter::new();
5020
5021        // Handle the protocol
5022        assert!(
5023            handle(
5024                &protocol,
5025                &state,
5026                &RenderOptions::new("index.html", "/"),
5027                &mut writer
5028            )
5029            .is_ok(),
5030            "Failed to handle signal protocol"
5031        );
5032
5033        // Check the output
5034        assert_eq!(writer.get_content(), "Hello, WebUI!");
5035        assert!(writer.is_ended());
5036    }
5037
5038    #[test]
5039    fn test_handle_for_loop() {
5040        // Create a protocol with a for loop
5041        let mut fragments = HashMap::new();
5042        fragments.insert(
5043            "index.html".to_string(),
5044            FragmentList {
5045                fragments: vec![
5046                    WebUIFragment::raw("People: "),
5047                    WebUIFragment::for_loop("person", "people", "person-item"),
5048                ],
5049                contains_boundary: false,
5050            },
5051        );
5052
5053        fragments.insert(
5054            "person-item".to_string(),
5055            FragmentList {
5056                fragments: vec![
5057                    WebUIFragment::signal("person.name", false),
5058                    WebUIFragment::raw(", "),
5059                ],
5060                contains_boundary: false,
5061            },
5062        );
5063
5064        let protocol = WebUIProtocol::new(fragments);
5065        let state = test_json!({
5066            "people": [
5067                {"name": "Alice"},
5068                {"name": "Bob"},
5069                {"name": "Charlie"}
5070            ]
5071        });
5072
5073        // Create a test writer
5074        let mut writer = TestWriter::new();
5075
5076        // Handle the protocol
5077        assert!(
5078            handle(
5079                &protocol,
5080                &state,
5081                &RenderOptions::new("index.html", "/"),
5082                &mut writer
5083            )
5084            .is_ok(),
5085            "Failed to handle for loop protocol"
5086        );
5087
5088        // Check the output
5089        assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
5090        assert!(writer.is_ended());
5091    }
5092
5093    #[test]
5094    fn test_handle_if_condition() {
5095        // Create a protocol with an if condition
5096        let mut fragments = HashMap::new();
5097        fragments.insert(
5098            "index.html".to_string(),
5099            FragmentList {
5100                fragments: vec![
5101                    WebUIFragment::raw("Status: "),
5102                    WebUIFragment::if_cond(
5103                        webui_protocol::ConditionExpr::identifier("isActive"),
5104                        "active-content",
5105                    ),
5106                    WebUIFragment::raw("End"),
5107                ],
5108                contains_boundary: false,
5109            },
5110        );
5111
5112        fragments.insert(
5113            "active-content".to_string(),
5114            FragmentList {
5115                fragments: vec![WebUIFragment::raw("Active")],
5116                contains_boundary: false,
5117            },
5118        );
5119
5120        let protocol = WebUIProtocol::new(fragments);
5121
5122        // Test with isActive = true
5123        let state_true = test_json!({"isActive": true});
5124        let mut writer_true = TestWriter::new();
5125        assert!(
5126            handle(
5127                &protocol,
5128                &state_true,
5129                &RenderOptions::new("index.html", "/"),
5130                &mut writer_true
5131            )
5132            .is_ok(),
5133            "Failed to handle if condition (true case)"
5134        );
5135        assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
5136        assert!(writer_true.is_ended());
5137
5138        // Test with isActive = false
5139        let state_false = test_json!({"isActive": false});
5140        let mut writer_false = TestWriter::new();
5141        assert!(
5142            handle(
5143                &protocol,
5144                &state_false,
5145                &RenderOptions::new("index.html", "/"),
5146                &mut writer_false
5147            )
5148            .is_ok(),
5149            "Failed to handle if condition (false case)"
5150        );
5151        assert_eq!(writer_false.get_content(), "Status: End");
5152        assert!(writer_false.is_ended());
5153    }
5154
5155    #[test]
5156    fn missing_identifier_is_falsy_before_negation_in_global_and_loop_scopes() {
5157        let source = r#"<if condition="missingTopLevel">top-positive</if><if condition="!missingTopLevel">top-negated</if><for each="item in items"><if condition="item.searchPresentation"><mark>{{item.label}}</mark></if><if condition="!item.searchPresentation"><span>{{item.label}}</span></if></for>"#;
5158        let mut parser = HtmlParser::new();
5159        parser
5160            .parse("index.html", source)
5161            .expect("parse missing-path condition fixture");
5162        let protocol = WebUIProtocol::new(parser.into_fragment_records());
5163        let state = test_json!({
5164            "items": [
5165                {"label": "Normal"},
5166                {"label": "Search", "searchPresentation": true}
5167            ]
5168        });
5169        let mut writer = TestWriter::new();
5170
5171        handle(
5172            &protocol,
5173            &state,
5174            &RenderOptions::new("index.html", "/"),
5175            &mut writer,
5176        )
5177        .expect("render missing-path condition fixture");
5178
5179        assert_eq!(
5180            writer.get_content(),
5181            "top-negated<span>Normal</span><mark>Search</mark>"
5182        );
5183    }
5184
5185    #[test]
5186    fn native_dynamic_attribute_does_not_leak_into_next_component() {
5187        let mut fragments = HashMap::new();
5188        fragments.insert(
5189            "index.html".to_string(),
5190            FragmentList {
5191                fragments: vec![
5192                    WebUIFragment::raw("<div"),
5193                    WebUIFragment {
5194                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5195                            WebUIFragmentAttribute {
5196                                name: "title".into(),
5197                                value: "nativeTitle".into(),
5198                                ..Default::default()
5199                            },
5200                        )),
5201                    },
5202                    WebUIFragment::raw("></div><my-comp>"),
5203                    WebUIFragment::component("my-comp"),
5204                    WebUIFragment::raw("</my-comp>"),
5205                ],
5206                contains_boundary: false,
5207            },
5208        );
5209        fragments.insert(
5210            "my-comp".to_string(),
5211            FragmentList {
5212                fragments: vec![WebUIFragment::signal("title", false)],
5213                contains_boundary: false,
5214            },
5215        );
5216        let protocol = WebUIProtocol::new(fragments);
5217        let state = test_json!({
5218            "nativeTitle": "native",
5219            "title": "global"
5220        });
5221        let mut writer = TestWriter::new();
5222        handle(
5223            &protocol,
5224            &state,
5225            &RenderOptions::new("index.html", "/"),
5226            &mut writer,
5227        )
5228        .unwrap_or_else(|error| panic!("render failed: {error}"));
5229        assert_eq!(
5230            writer.get_content(),
5231            "<div title=\"native\"></div><my-comp>global</my-comp>"
5232        );
5233    }
5234
5235    #[test]
5236    fn test_handle_component() {
5237        // Create a protocol with a component
5238        let mut fragments = HashMap::new();
5239        fragments.insert(
5240            "index.html".to_string(),
5241            FragmentList {
5242                fragments: vec![
5243                    WebUIFragment::raw("Component: "),
5244                    WebUIFragment::component("my-component"),
5245                ],
5246                contains_boundary: false,
5247            },
5248        );
5249
5250        fragments.insert(
5251            "my-component".to_string(),
5252            FragmentList {
5253                fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
5254                contains_boundary: false,
5255            },
5256        );
5257
5258        let protocol = WebUIProtocol::new(fragments);
5259        let state = test_json!({});
5260
5261        // Create a test writer
5262        let mut writer = TestWriter::new();
5263
5264        // Handle the protocol
5265        assert!(
5266            handle(
5267                &protocol,
5268                &state,
5269                &RenderOptions::new("index.html", "/"),
5270                &mut writer
5271            )
5272            .is_ok(),
5273            "Failed to handle component protocol"
5274        );
5275
5276        // Check the output
5277        assert_eq!(
5278            writer.get_content(),
5279            "Component: <div>Component Content</div>"
5280        );
5281        assert!(writer.is_ended());
5282    }
5283
5284    #[test]
5285    fn test_missing_fragment() {
5286        // Create a protocol with a missing fragment reference
5287        let mut fragments = HashMap::new();
5288        fragments.insert(
5289            "index.html".to_string(),
5290            FragmentList {
5291                fragments: vec![WebUIFragment::component("missing-component")],
5292                contains_boundary: false,
5293            },
5294        );
5295
5296        let protocol = WebUIProtocol::new(fragments);
5297        let state = test_json!({});
5298
5299        // Create a test writer
5300        let mut writer = TestWriter::new();
5301
5302        // Handle the protocol
5303        let result = handle(
5304            &protocol,
5305            &state,
5306            &RenderOptions::new("index.html", "/"),
5307            &mut writer,
5308        );
5309
5310        // Expect an error
5311        assert!(result.is_err());
5312        if let Err(HandlerError::MissingFragment(fragment_id)) = result {
5313            assert_eq!(fragment_id, "missing-component");
5314        } else {
5315            panic!("Expected MissingFragment error");
5316        }
5317    }
5318
5319    #[test]
5320    fn test_missing_signal_renders_empty() {
5321        // A signal referencing a field absent from state should render as empty
5322        let mut fragments = HashMap::new();
5323        fragments.insert(
5324            "index.html".to_string(),
5325            FragmentList {
5326                fragments: vec![
5327                    WebUIFragment::raw("Hello, "),
5328                    WebUIFragment::signal("missing_field", false),
5329                    WebUIFragment::raw("!"),
5330                ],
5331                contains_boundary: false,
5332            },
5333        );
5334
5335        let protocol = WebUIProtocol::new(fragments);
5336        let state = test_json!({});
5337
5338        let mut writer = TestWriter::new();
5339
5340        assert!(
5341            handle(
5342                &protocol,
5343                &state,
5344                &RenderOptions::new("index.html", "/"),
5345                &mut writer
5346            )
5347            .is_ok(),
5348            "Missing signal should not produce an error"
5349        );
5350
5351        assert_eq!(writer.get_content(), "Hello, !");
5352        assert!(writer.is_ended());
5353    }
5354
5355    // ── Boolean attribute rendering tests ─────────────────────────────
5356
5357    #[test]
5358    fn test_boolean_attr_true() {
5359        let mut fragments = HashMap::new();
5360        fragments.insert(
5361            "index.html".to_string(),
5362            FragmentList {
5363                fragments: vec![
5364                    WebUIFragment::raw("<button"),
5365                    WebUIFragment::attribute_boolean(
5366                        "disabled",
5367                        ConditionExpr::identifier("isDisabled"),
5368                    ),
5369                    WebUIFragment::raw(">Click</button>"),
5370                ],
5371                contains_boundary: false,
5372            },
5373        );
5374        let protocol = WebUIProtocol::new(fragments);
5375        let state = test_json!({"isDisabled": true});
5376        let mut writer = TestWriter::new();
5377        handle(
5378            &protocol,
5379            &state,
5380            &RenderOptions::new("index.html", "/"),
5381            &mut writer,
5382        )
5383        .unwrap();
5384        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
5385    }
5386
5387    #[test]
5388    fn test_boolean_attr_false() {
5389        let mut fragments = HashMap::new();
5390        fragments.insert(
5391            "index.html".to_string(),
5392            FragmentList {
5393                fragments: vec![
5394                    WebUIFragment::raw("<button"),
5395                    WebUIFragment::attribute_boolean(
5396                        "disabled",
5397                        ConditionExpr::identifier("isDisabled"),
5398                    ),
5399                    WebUIFragment::raw(">Click</button>"),
5400                ],
5401                contains_boundary: false,
5402            },
5403        );
5404        let protocol = WebUIProtocol::new(fragments);
5405        let state = test_json!({"isDisabled": false});
5406        let mut writer = TestWriter::new();
5407        handle(
5408            &protocol,
5409            &state,
5410            &RenderOptions::new("index.html", "/"),
5411            &mut writer,
5412        )
5413        .unwrap();
5414        assert_eq!(writer.get_content(), "<button>Click</button>");
5415    }
5416
5417    #[test]
5418    fn test_boolean_attr_missing() {
5419        let mut fragments = HashMap::new();
5420        fragments.insert(
5421            "index.html".to_string(),
5422            FragmentList {
5423                fragments: vec![
5424                    WebUIFragment::raw("<input type=\"checkbox\""),
5425                    WebUIFragment::attribute_boolean(
5426                        "checked",
5427                        ConditionExpr::identifier("checked"),
5428                    ),
5429                    WebUIFragment::raw(">"),
5430                ],
5431                contains_boundary: false,
5432            },
5433        );
5434        let protocol = WebUIProtocol::new(fragments);
5435        let state = test_json!({});
5436        let mut writer = TestWriter::new();
5437        handle(
5438            &protocol,
5439            &state,
5440            &RenderOptions::new("index.html", "/"),
5441            &mut writer,
5442        )
5443        .unwrap();
5444        assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
5445    }
5446
5447    #[test]
5448    fn test_boolean_attr_multiple() {
5449        let mut fragments = HashMap::new();
5450        fragments.insert(
5451            "index.html".to_string(),
5452            FragmentList {
5453                fragments: vec![
5454                    WebUIFragment::raw("<input type=\"checkbox\""),
5455                    WebUIFragment::attribute_boolean(
5456                        "checked",
5457                        ConditionExpr::identifier("checked"),
5458                    ),
5459                    WebUIFragment::attribute_boolean(
5460                        "disabled",
5461                        ConditionExpr::identifier("disabled"),
5462                    ),
5463                    WebUIFragment::raw(">"),
5464                ],
5465                contains_boundary: false,
5466            },
5467        );
5468        let protocol = WebUIProtocol::new(fragments);
5469        let state = test_json!({"checked": true, "disabled": false});
5470        let mut writer = TestWriter::new();
5471        handle(
5472            &protocol,
5473            &state,
5474            &RenderOptions::new("index.html", "/"),
5475            &mut writer,
5476        )
5477        .unwrap();
5478        assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
5479    }
5480
5481    // ── Simple attribute rendering tests ──────────────────────────────
5482
5483    #[test]
5484    fn test_attribute_with_value() {
5485        let mut fragments = HashMap::new();
5486        fragments.insert(
5487            "index.html".to_string(),
5488            FragmentList {
5489                fragments: vec![
5490                    WebUIFragment::raw("<input"),
5491                    WebUIFragment::attribute("value", "inputValue"),
5492                    WebUIFragment::raw(">"),
5493                ],
5494                contains_boundary: false,
5495            },
5496        );
5497        let protocol = WebUIProtocol::new(fragments);
5498        let state = test_json!({"inputValue": "Hello"});
5499        let mut writer = TestWriter::new();
5500        handle(
5501            &protocol,
5502            &state,
5503            &RenderOptions::new("index.html", "/"),
5504            &mut writer,
5505        )
5506        .unwrap();
5507        assert_eq!(writer.get_content(), "<input value=\"Hello\">");
5508    }
5509
5510    #[test]
5511    fn test_dynamic_attribute_preserves_nullish_and_falsy_values() -> Result<()> {
5512        let mut fragments = HashMap::new();
5513        fragments.insert(
5514            "index.html".to_string(),
5515            FragmentList {
5516                fragments: vec![
5517                    WebUIFragment::raw("<input"),
5518                    WebUIFragment::attribute("pattern", "pattern"),
5519                    WebUIFragment::raw(">"),
5520                ],
5521                contains_boundary: false,
5522            },
5523        );
5524        let protocol = WebUIProtocol::new(fragments);
5525
5526        for (state, expected) in [
5527            (test_json!({}), "<input>"),
5528            (test_json!({"pattern": null}), "<input>"),
5529            (test_json!({"pattern": ""}), "<input pattern=\"\">"),
5530            (test_json!({"pattern": 0}), "<input pattern=\"0\">"),
5531            (test_json!({"pattern": false}), "<input pattern=\"false\">"),
5532            (
5533                test_json!({"pattern": "[a-z]+"}),
5534                "<input pattern=\"[a-z]+\">",
5535            ),
5536        ] {
5537            let mut writer = TestWriter::new();
5538            handle(
5539                &protocol,
5540                &state,
5541                &RenderOptions::new("index.html", "/"),
5542                &mut writer,
5543            )?;
5544            assert_eq!(writer.get_content(), expected, "state: {state}");
5545        }
5546        Ok(())
5547    }
5548
5549    #[test]
5550    fn test_dynamic_attribute_omission_preserves_component_props() -> Result<()> {
5551        let mut fragments = HashMap::new();
5552        fragments.insert(
5553            "index.html".to_string(),
5554            FragmentList {
5555                fragments: vec![
5556                    WebUIFragment::raw("<my-input"),
5557                    WebUIFragment {
5558                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5559                            WebUIFragmentAttribute {
5560                                name: "pattern".into(),
5561                                value: "constraint".into(),
5562                                attr_start: true,
5563                                ..Default::default()
5564                            },
5565                        )),
5566                    },
5567                    WebUIFragment::raw(">"),
5568                    WebUIFragment::component("my-input"),
5569                    WebUIFragment::raw("</my-input>"),
5570                ],
5571                contains_boundary: false,
5572            },
5573        );
5574        fragments.insert(
5575            "my-input".to_string(),
5576            FragmentList {
5577                fragments: vec![
5578                    WebUIFragment::raw("<input"),
5579                    WebUIFragment::attribute("pattern", "pattern"),
5580                    WebUIFragment::raw(">"),
5581                    WebUIFragment::signal("pattern", false),
5582                ],
5583                contains_boundary: false,
5584            },
5585        );
5586        let protocol = WebUIProtocol::new(fragments);
5587
5588        for (state, expected) in [
5589            (test_json!({}), "<my-input><input pattern=\"\"></my-input>"),
5590            (
5591                test_json!({"constraint": null}),
5592                "<my-input><input>null</my-input>",
5593            ),
5594            (
5595                test_json!({"pattern": "global"}),
5596                "<my-input><input pattern=\"\"></my-input>",
5597            ),
5598            (
5599                test_json!({"constraint": ""}),
5600                "<my-input pattern=\"\"><input pattern=\"\"></my-input>",
5601            ),
5602            (
5603                test_json!({"constraint": "[a-z]+"}),
5604                "<my-input pattern=\"[a-z]+\"><input pattern=\"[a-z]+\">[a-z]+</my-input>",
5605            ),
5606        ] {
5607            let mut writer = TestWriter::new();
5608            handle(
5609                &protocol,
5610                &state,
5611                &RenderOptions::new("index.html", "/"),
5612                &mut writer,
5613            )?;
5614            assert_eq!(writer.get_content(), expected, "state: {state}");
5615        }
5616        Ok(())
5617    }
5618
5619    #[test]
5620    fn test_attribute_with_falsy_numeric() {
5621        let mut fragments = HashMap::new();
5622        fragments.insert(
5623            "index.html".to_string(),
5624            FragmentList {
5625                fragments: vec![
5626                    WebUIFragment::raw("<div name=\"test\""),
5627                    WebUIFragment::attribute("handle", "number"),
5628                    WebUIFragment::raw("></div>"),
5629                ],
5630                contains_boundary: false,
5631            },
5632        );
5633        let protocol = WebUIProtocol::new(fragments);
5634        let state = test_json!({"number": 0});
5635        let mut writer = TestWriter::new();
5636        handle(
5637            &protocol,
5638            &state,
5639            &RenderOptions::new("index.html", "/"),
5640            &mut writer,
5641        )
5642        .unwrap();
5643        assert_eq!(
5644            writer.get_content(),
5645            "<div name=\"test\" handle=\"0\"></div>"
5646        );
5647    }
5648
5649    // ── Dynamic attribute escaping for non-string JSON types ─────────
5650
5651    #[test]
5652    fn test_attribute_array_value_is_escaped() {
5653        let mut fragments = HashMap::new();
5654        fragments.insert(
5655            "index.html".to_string(),
5656            FragmentList {
5657                fragments: vec![
5658                    WebUIFragment::raw("<a"),
5659                    WebUIFragment::attribute("href", "value"),
5660                    WebUIFragment::raw(">demo</a>"),
5661                ],
5662                contains_boundary: false,
5663            },
5664        );
5665        let protocol = WebUIProtocol::new(fragments);
5666        let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
5667        let mut writer = TestWriter::new();
5668        handle(
5669            &protocol,
5670            &state,
5671            &RenderOptions::new("index.html", "/"),
5672            &mut writer,
5673        )
5674        .unwrap();
5675        let content = writer.get_content();
5676        // All inner double quotes must be entity-escaped so that the
5677        // browser never sees a second attribute boundary.
5678        assert!(
5679            content.contains("&quot;"),
5680            "Double quotes inside attribute value must be escaped: {content}"
5681        );
5682        // The href attribute value must be a single contiguous quoted
5683        // string — no extra attributes should appear.
5684        assert_eq!(
5685            content.matches("=\"").count(),
5686            1,
5687            "Only one attribute assignment expected: {content}"
5688        );
5689    }
5690
5691    #[test]
5692    fn test_attribute_object_value_is_escaped() {
5693        let mut fragments = HashMap::new();
5694        fragments.insert(
5695            "index.html".to_string(),
5696            FragmentList {
5697                fragments: vec![
5698                    WebUIFragment::raw("<div"),
5699                    WebUIFragment::attribute("data-cfg", "cfg"),
5700                    WebUIFragment::raw("></div>"),
5701                ],
5702                contains_boundary: false,
5703            },
5704        );
5705        let protocol = WebUIProtocol::new(fragments);
5706        let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
5707        let mut writer = TestWriter::new();
5708        handle(
5709            &protocol,
5710            &state,
5711            &RenderOptions::new("index.html", "/"),
5712            &mut writer,
5713        )
5714        .unwrap();
5715        let content = writer.get_content();
5716        assert!(
5717            content.contains("&quot;"),
5718            "Double quotes inside attribute value must be escaped: {content}"
5719        );
5720        assert_eq!(
5721            content.matches("=\"").count(),
5722            1,
5723            "Only one attribute assignment expected: {content}"
5724        );
5725    }
5726
5727    // ── Template attribute rendering tests ────────────────────────────
5728
5729    #[test]
5730    fn test_mixed_attribute_template() {
5731        let mut fragments = HashMap::new();
5732        fragments.insert(
5733            "index.html".to_string(),
5734            FragmentList {
5735                fragments: vec![
5736                    WebUIFragment::raw("<input"),
5737                    WebUIFragment::attribute_template("value", "attr-1"),
5738                    WebUIFragment::raw(">"),
5739                ],
5740                contains_boundary: false,
5741            },
5742        );
5743        fragments.insert(
5744            "attr-1".to_string(),
5745            FragmentList {
5746                fragments: vec![
5747                    WebUIFragment::raw("hello "),
5748                    WebUIFragment::signal("item", false),
5749                ],
5750                contains_boundary: false,
5751            },
5752        );
5753        let protocol = WebUIProtocol::new(fragments);
5754        let state = test_json!({"item": "world"});
5755        let mut writer = TestWriter::new();
5756        handle(
5757            &protocol,
5758            &state,
5759            &RenderOptions::new("index.html", "/"),
5760            &mut writer,
5761        )
5762        .unwrap();
5763        assert_eq!(writer.get_content(), "<input value=\"hello world\">");
5764    }
5765
5766    // ── Raw signal rendering test ─────────────────────────────────────
5767
5768    #[test]
5769    fn test_raw_signal_not_escaped() {
5770        let mut fragments = HashMap::new();
5771        fragments.insert(
5772            "index.html".to_string(),
5773            FragmentList {
5774                fragments: vec![
5775                    WebUIFragment::signal("html", false),
5776                    WebUIFragment::signal("html", true),
5777                ],
5778                contains_boundary: false,
5779            },
5780        );
5781        let protocol = WebUIProtocol::new(fragments);
5782        let state = test_json!({"html": "<strong>hi</strong>"});
5783        let mut writer = TestWriter::new();
5784        handle(
5785            &protocol,
5786            &state,
5787            &RenderOptions::new("index.html", "/"),
5788            &mut writer,
5789        )
5790        .unwrap();
5791        assert_eq!(
5792            writer.get_content(),
5793            "&lt;strong&gt;hi&lt;&#x2F;strong&gt;<strong>hi</strong>"
5794        );
5795    }
5796
5797    // ── Nested for loop tests ─────────────────────────────────────────
5798
5799    fn render_named_for(template: &str, state: &Value) -> String {
5800        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5801        parser.parse("index.html", template).unwrap();
5802        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
5803        let mut writer = TestWriter::new();
5804        WebUIHandler::new()
5805            .render(
5806                &protocol,
5807                state,
5808                &RenderOptions::new("index.html", "/"),
5809                &mut writer,
5810            )
5811            .unwrap();
5812        writer.get_content()
5813    }
5814
5815    #[test]
5816    fn named_for_renders_the_issue_tree_and_restores_shadowed_items() {
5817        let source = r#"<ul><for each="child in items" id="tree-item"><li>{{child.name}}</li><if condition="{{child.children}}"><ul><for id="tree-item" each="child in child.children" /></ul></if><i>{{child.name}}</i></for></ul><b>{{child.name}}</b>"#;
5818        let state = test_json!({
5819            "child": { "name": "global" },
5820            "items": [
5821                { "name": "Colors", "children": [{ "name": "Ali" }, { "name": "Alice" }, { "name": "Bob" }] },
5822                { "name": "Name", "children": [] },
5823                { "name": "Hobbies", "children": [{ "name": "Sports", "children": [{ "name": "Futbol" }, { "name": "Cricket" }] }] }
5824            ]
5825        });
5826        assert_eq!(render_named_for(source, &state), "<ul><li>Colors</li><ul><li>Ali</li><i>Ali</i><li>Alice</li><i>Alice</i><li>Bob</li><i>Bob</i></ul><i>Colors</i><li>Name</li><i>Name</i><li>Hobbies</li><ul><li>Sports</li><ul><li>Futbol</li><i>Futbol</i><li>Cricket</li><i>Cricket</i></ul><i>Sports</i></ul><i>Hobbies</i></ul><b>global</b>");
5827    }
5828
5829    #[test]
5830    fn named_for_missing_and_empty_children_stop_recursion_without_an_if() {
5831        let source = r#"<for id="tree" each="item in items">{{item.name}}<for id="tree" each="item in item.children" />{{item.name}}</for>"#;
5832        let state = test_json!({
5833            "items": [{ "name": "a", "children": [{ "name": "b" }, { "name": "c", "children": [] }] }]
5834        });
5835        assert_eq!(render_named_for(source, &state), "abbcca");
5836        assert_eq!(render_named_for(source, &test_json!({"items": []})), "");
5837    }
5838
5839    #[test]
5840    fn named_for_forward_and_mutual_references_follow_finite_data() {
5841        let source = r#"<for id="a" each="item in items" /><for id="a" each="item in empty"><b>{{item.name}}</b><for id="b" each="item in item.children" /></for><for id="b" each="item in empty"><i>{{item.name}}</i><for id="a" each="item in item.children" /></for>"#;
5842        let state = test_json!({
5843            "items": [{ "name": "a", "children": [{ "name": "b", "children": [{ "name": "c" }] }] }],
5844            "empty": []
5845        });
5846        assert_eq!(render_named_for(source, &state), "<b>a</b><i>b</i><b>c</b>");
5847    }
5848
5849    #[test]
5850    fn named_for_preserves_enclosing_scope_and_escapes_nested_values() {
5851        let source = r#"<for each="group in groups"><for id="tree" each="item in group.items">{{group.name}}:{{item.name}};<for id="tree" each="item in item.children" /></for></for>"#;
5852        let state = test_json!({
5853            "groups": [
5854                { "name": "one", "items": [{ "name": "<root>", "children": [{ "name": "&leaf" }] }] },
5855                { "name": "two", "items": [{ "name": "last" }] }
5856            ]
5857        });
5858        assert_eq!(
5859            render_named_for(source, &state),
5860            "one:&lt;root&gt;;one:&amp;leaf;two:last;"
5861        );
5862    }
5863
5864    #[test]
5865    fn named_for_non_array_collection_returns_the_existing_type_error() {
5866        let mut parser = HtmlParser::new();
5867        parser.parse("index.html", r#"<for id="tree" each="item in items"><for id="tree" each="item in item.children" /></for>"#).unwrap();
5868        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
5869        let mut writer = TestWriter::new();
5870        let error = WebUIHandler::new()
5871            .render(
5872                &protocol,
5873                &test_json!({"items": [{"children": 1}]}),
5874                &RenderOptions::new("index.html", "/"),
5875                &mut writer,
5876            )
5877            .unwrap_err();
5878        assert!(matches!(error, HandlerError::TypeError(_)));
5879    }
5880
5881    #[test]
5882    fn test_nested_for_loop() {
5883        let mut fragments = HashMap::new();
5884        fragments.insert(
5885            "index.html".to_string(),
5886            FragmentList {
5887                fragments: vec![
5888                    WebUIFragment::raw("<div>"),
5889                    WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
5890                    WebUIFragment::raw("</div>"),
5891                ],
5892                contains_boundary: false,
5893            },
5894        );
5895        fragments.insert(
5896            "outer".to_string(),
5897            FragmentList {
5898                fragments: vec![
5899                    WebUIFragment::raw("<div>"),
5900                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
5901                    WebUIFragment::raw("</div>"),
5902                ],
5903                contains_boundary: false,
5904            },
5905        );
5906        fragments.insert(
5907            "inner".to_string(),
5908            FragmentList {
5909                fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
5910                contains_boundary: false,
5911            },
5912        );
5913        let protocol = WebUIProtocol::new(fragments);
5914        let state = test_json!({
5915            "outerItems": [
5916                {"innerItems": [{"name": "A"}, {"name": "B"}]},
5917                {"innerItems": [{"name": "C"}]}
5918            ]
5919        });
5920        let mut writer = TestWriter::new();
5921        handle(
5922            &protocol,
5923            &state,
5924            &RenderOptions::new("index.html", "/"),
5925            &mut writer,
5926        )
5927        .unwrap();
5928        assert_eq!(
5929            writer.get_content(),
5930            "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
5931        );
5932    }
5933
5934    #[test]
5935    fn test_nested_for_with_signals() {
5936        let mut fragments = HashMap::new();
5937        fragments.insert(
5938            "index.html".to_string(),
5939            FragmentList {
5940                fragments: vec![
5941                    WebUIFragment::raw("<div>"),
5942                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5943                    WebUIFragment::raw("</div>"),
5944                ],
5945                contains_boundary: false,
5946            },
5947        );
5948        fragments.insert(
5949            "outerTemplate".to_string(),
5950            FragmentList {
5951                fragments: vec![
5952                    WebUIFragment::raw("<div>"),
5953                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5954                    WebUIFragment::raw("</div>"),
5955                ],
5956                contains_boundary: false,
5957            },
5958        );
5959        fragments.insert(
5960            "innerTemplate".to_string(),
5961            FragmentList {
5962                fragments: vec![
5963                    WebUIFragment::raw("<span>"),
5964                    WebUIFragment::signal("innerItem.name", false),
5965                    WebUIFragment::raw("</span>"),
5966                ],
5967                contains_boundary: false,
5968            },
5969        );
5970        let protocol = WebUIProtocol::new(fragments);
5971        let state = test_json!({
5972            "outerItems": [
5973                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5974                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5975            ]
5976        });
5977        let mut writer = TestWriter::new();
5978        handle(
5979            &protocol,
5980            &state,
5981            &RenderOptions::new("index.html", "/"),
5982            &mut writer,
5983        )
5984        .unwrap();
5985        assert_eq!(
5986            writer.get_content(),
5987            "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
5988        );
5989    }
5990
5991    #[test]
5992    fn test_nested_for_with_global_state() {
5993        let mut fragments = HashMap::new();
5994        fragments.insert(
5995            "index.html".to_string(),
5996            FragmentList {
5997                fragments: vec![
5998                    WebUIFragment::raw("<div>"),
5999                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
6000                    WebUIFragment::raw("</div>"),
6001                ],
6002                contains_boundary: false,
6003            },
6004        );
6005        fragments.insert(
6006            "outerTemplate".to_string(),
6007            FragmentList {
6008                fragments: vec![
6009                    WebUIFragment::raw("<div>"),
6010                    WebUIFragment::signal("globalOuter", false),
6011                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
6012                    WebUIFragment::raw("</div>"),
6013                ],
6014                contains_boundary: false,
6015            },
6016        );
6017        fragments.insert(
6018            "innerTemplate".to_string(),
6019            FragmentList {
6020                fragments: vec![
6021                    WebUIFragment::raw("<span>"),
6022                    WebUIFragment::signal("innerItem.name", false),
6023                    WebUIFragment::signal("globalInner", false),
6024                    WebUIFragment::raw("</span>"),
6025                ],
6026                contains_boundary: false,
6027            },
6028        );
6029        let protocol = WebUIProtocol::new(fragments);
6030        let state = test_json!({
6031            "globalOuter": "GO",
6032            "globalInner": "GI",
6033            "outerItems": [
6034                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
6035                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
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><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
6049        );
6050    }
6051
6052    // ── For + If state scoping tests ──────────────────────────────────
6053
6054    #[test]
6055    fn test_if_in_for_uses_local_state() {
6056        let mut fragments = HashMap::new();
6057        fragments.insert(
6058            "index.html".to_string(),
6059            FragmentList {
6060                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
6061                contains_boundary: false,
6062            },
6063        );
6064        fragments.insert(
6065            "item-tpl".to_string(),
6066            FragmentList {
6067                fragments: vec![WebUIFragment::if_cond(
6068                    ConditionExpr::identifier("item.visible"),
6069                    "visible-tpl",
6070                )],
6071                contains_boundary: false,
6072            },
6073        );
6074        fragments.insert(
6075            "visible-tpl".to_string(),
6076            FragmentList {
6077                fragments: vec![WebUIFragment::signal("item.name", false)],
6078                contains_boundary: false,
6079            },
6080        );
6081        let protocol = WebUIProtocol::new(fragments);
6082        let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
6083        let mut writer = TestWriter::new();
6084        handle(
6085            &protocol,
6086            &state,
6087            &RenderOptions::new("index.html", "/"),
6088            &mut writer,
6089        )
6090        .unwrap();
6091        assert_eq!(writer.get_content(), "Show");
6092    }
6093
6094    #[test]
6095    fn test_for_if_local_overrides_global() {
6096        let mut fragments = HashMap::new();
6097        fragments.insert(
6098            "index.html".to_string(),
6099            FragmentList {
6100                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
6101                contains_boundary: false,
6102            },
6103        );
6104        fragments.insert(
6105            "item-tpl".to_string(),
6106            FragmentList {
6107                fragments: vec![WebUIFragment::if_cond(
6108                    ConditionExpr::identifier("item.flag"),
6109                    "show-tpl",
6110                )],
6111                contains_boundary: false,
6112            },
6113        );
6114        fragments.insert(
6115            "show-tpl".to_string(),
6116            FragmentList {
6117                fragments: vec![WebUIFragment::raw("yes")],
6118                contains_boundary: false,
6119            },
6120        );
6121        let protocol = WebUIProtocol::new(fragments);
6122        // Global flag is true, but local item.flag is false for second item
6123        let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
6124        let mut writer = TestWriter::new();
6125        handle(
6126            &protocol,
6127            &state,
6128            &RenderOptions::new("index.html", "/"),
6129            &mut writer,
6130        )
6131        .unwrap();
6132        assert_eq!(writer.get_content(), "yes");
6133    }
6134
6135    // ── Component attribute state tests ───────────────────────────────
6136
6137    #[test]
6138    fn html_component_inputs_forward_flags_and_aria_without_javascript_metadata() {
6139        let cases = [
6140            (
6141                r#"<my-drawer open aria-label="Canvas information"></my-drawer>"#,
6142                test_json!({}),
6143                r#"<dialog open aria-label="Canvas information"></dialog>"#,
6144            ),
6145            (
6146                r#"<my-drawer ?open="{{open}}" aria-label="Canvas information"></my-drawer>"#,
6147                test_json!({"open": true}),
6148                r#"<dialog open aria-label="Canvas information"></dialog>"#,
6149            ),
6150            (
6151                r#"<my-drawer ?open="{{open}}" aria-label="Canvas information"></my-drawer>"#,
6152                test_json!({"open": false}),
6153                r#"<dialog aria-label="Canvas information"></dialog>"#,
6154            ),
6155            (
6156                r#"<my-drawer aria-label=""></my-drawer>"#,
6157                test_json!({"ariaLabel": "Fallback"}),
6158                r#"<dialog aria-label=""></dialog>"#,
6159            ),
6160            (
6161                r#"<my-drawer open aria-label="Canvas &amp; information"></my-drawer>"#,
6162                test_json!({}),
6163                r#"<dialog open aria-label="Canvas &amp; information"></dialog>"#,
6164            ),
6165        ];
6166        for (input, state, expected) in cases {
6167            let mut parser = HtmlParser::with_options(DomStrategy::Light);
6168            parser
6169                .component_registry_mut()
6170                .register_component(ComponentRegistration::new(
6171                    "my-drawer",
6172                    r#"<dialog ?open="{{open}}" aria-label="{{ariaLabel}}"></dialog>"#,
6173                    None,
6174                    true,
6175                ))
6176                .unwrap();
6177            parser.parse("index.html", input).unwrap();
6178            let protocol = WebUIProtocol::new(parser.into_fragment_records());
6179            let mut writer = TestWriter::new();
6180            handle(
6181                &protocol,
6182                &state,
6183                &RenderOptions::new("index.html", "/"),
6184                &mut writer,
6185            )
6186            .unwrap();
6187            assert!(
6188                writer.get_content().contains(expected),
6189                "{input}: {}",
6190                writer.get_content()
6191            );
6192        }
6193    }
6194
6195    #[test]
6196    fn test_component_attr_state_simple() {
6197        let mut fragments = HashMap::new();
6198        fragments.insert(
6199            "index.html".to_string(),
6200            FragmentList {
6201                fragments: vec![
6202                    WebUIFragment::raw("<my-comp"),
6203                    WebUIFragment {
6204                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6205                            WebUIFragmentAttribute {
6206                                name: "title".into(),
6207                                value: "Attribute Title".into(),
6208                                attr_start: true,
6209                                raw_value: true,
6210                                ..Default::default()
6211                            },
6212                        )),
6213                    },
6214                    WebUIFragment::raw(">"),
6215                    WebUIFragment::component("my-comp"),
6216                    WebUIFragment::raw("</my-comp>"),
6217                ],
6218
6219                contains_boundary: false,
6220            },
6221        );
6222        fragments.insert(
6223            "my-comp".to_string(),
6224            FragmentList {
6225                fragments: vec![
6226                    WebUIFragment::raw("<span>"),
6227                    WebUIFragment::signal("title", false),
6228                    WebUIFragment::raw("</span>"),
6229                ],
6230                contains_boundary: false,
6231            },
6232        );
6233        let protocol = WebUIProtocol::new(fragments);
6234        let state = test_json!({"title": "Global Title"});
6235        let mut writer = TestWriter::new();
6236        handle(
6237            &protocol,
6238            &state,
6239            &RenderOptions::new("index.html", "/"),
6240            &mut writer,
6241        )
6242        .unwrap();
6243        assert_eq!(
6244            writer.get_content(),
6245            "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
6246        );
6247    }
6248
6249    #[test]
6250    fn test_component_attr_state_template() {
6251        let mut fragments = HashMap::new();
6252        fragments.insert(
6253            "index.html".to_string(),
6254            FragmentList {
6255                fragments: vec![
6256                    WebUIFragment::raw("<my-comp"),
6257                    WebUIFragment {
6258                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6259                            WebUIFragmentAttribute {
6260                                name: "title".into(),
6261                                template: "title-attr".into(),
6262                                attr_start: true,
6263                                ..Default::default()
6264                            },
6265                        )),
6266                    },
6267                    WebUIFragment::raw(">"),
6268                    WebUIFragment::component("my-comp"),
6269                    WebUIFragment::raw("</my-comp>"),
6270                ],
6271
6272                contains_boundary: false,
6273            },
6274        );
6275        fragments.insert(
6276            "title-attr".to_string(),
6277            FragmentList {
6278                fragments: vec![
6279                    WebUIFragment::raw("hello "),
6280                    WebUIFragment::signal("item", false),
6281                ],
6282                contains_boundary: false,
6283            },
6284        );
6285        fragments.insert(
6286            "my-comp".to_string(),
6287            FragmentList {
6288                fragments: vec![
6289                    WebUIFragment::raw("<span>"),
6290                    WebUIFragment::signal("title", false),
6291                    WebUIFragment::raw("</span>"),
6292                ],
6293                contains_boundary: false,
6294            },
6295        );
6296        let protocol = WebUIProtocol::new(fragments);
6297        let state = test_json!({"item": "<world>"});
6298        let mut writer = TestWriter::new();
6299        handle(
6300            &protocol,
6301            &state,
6302            &RenderOptions::new("index.html", "/"),
6303            &mut writer,
6304        )
6305        .unwrap();
6306        assert_eq!(
6307            writer.get_content(),
6308            "<my-comp title=\"hello &lt;world&gt;\"><span>hello &lt;world&gt;</span></my-comp>"
6309        );
6310    }
6311
6312    #[test]
6313    fn test_component_attr_camel_case() {
6314        let mut fragments = HashMap::new();
6315        fragments.insert(
6316            "index.html".to_string(),
6317            FragmentList {
6318                fragments: vec![
6319                    WebUIFragment::raw("<my-comp"),
6320                    WebUIFragment {
6321                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6322                            WebUIFragmentAttribute {
6323                                name: "data-title".into(),
6324                                template: "dt-attr".into(),
6325                                attr_start: true,
6326                                ..Default::default()
6327                            },
6328                        )),
6329                    },
6330                    WebUIFragment::raw(">"),
6331                    WebUIFragment::component("my-comp"),
6332                    WebUIFragment::raw("</my-comp>"),
6333                ],
6334
6335                contains_boundary: false,
6336            },
6337        );
6338        fragments.insert(
6339            "dt-attr".to_string(),
6340            FragmentList {
6341                fragments: vec![
6342                    WebUIFragment::raw("prefix "),
6343                    WebUIFragment::signal("item", false),
6344                ],
6345                contains_boundary: false,
6346            },
6347        );
6348        fragments.insert(
6349            "my-comp".to_string(),
6350            FragmentList {
6351                fragments: vec![
6352                    WebUIFragment::raw("<span>"),
6353                    WebUIFragment::signal("dataTitle", false),
6354                    WebUIFragment::raw("</span>"),
6355                ],
6356                contains_boundary: false,
6357            },
6358        );
6359        let protocol = WebUIProtocol::new(fragments);
6360        let state = test_json!({"item": "a&b"});
6361        let mut writer = TestWriter::new();
6362        handle(
6363            &protocol,
6364            &state,
6365            &RenderOptions::new("index.html", "/"),
6366            &mut writer,
6367        )
6368        .unwrap();
6369        assert_eq!(
6370            writer.get_content(),
6371            "<my-comp data-title=\"prefix a&amp;b\"><span>prefix a&amp;b</span></my-comp>"
6372        );
6373    }
6374
6375    #[test]
6376    fn test_component_complex_attr() {
6377        let mut fragments = HashMap::new();
6378        fragments.insert(
6379            "index.html".to_string(),
6380            FragmentList {
6381                fragments: vec![
6382                    WebUIFragment::raw("<my-comp"),
6383                    WebUIFragment {
6384                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6385                            WebUIFragmentAttribute {
6386                                name: ":item".into(),
6387                                value: "complexItem".into(),
6388                                attr_start: true,
6389                                complex: true,
6390                                ..Default::default()
6391                            },
6392                        )),
6393                    },
6394                    WebUIFragment::raw(">"),
6395                    WebUIFragment::component("my-comp"),
6396                    WebUIFragment::raw("</my-comp>"),
6397                ],
6398
6399                contains_boundary: false,
6400            },
6401        );
6402        fragments.insert(
6403            "my-comp".to_string(),
6404            FragmentList {
6405                fragments: vec![
6406                    WebUIFragment::raw("<span>"),
6407                    WebUIFragment::signal("item.foo", false),
6408                    WebUIFragment::raw("</span><p>"),
6409                    WebUIFragment::signal("item.bar", false),
6410                    WebUIFragment::raw("</p>"),
6411                ],
6412                contains_boundary: false,
6413            },
6414        );
6415        let protocol = WebUIProtocol::new(fragments);
6416        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
6417        let mut writer = TestWriter::new();
6418        handle(
6419            &protocol,
6420            &state,
6421            &RenderOptions::new("index.html", "/"),
6422            &mut writer,
6423        )
6424        .unwrap();
6425        assert_eq!(
6426            writer.get_content(),
6427            "<my-comp><span>1</span><p>true</p></my-comp>"
6428        );
6429    }
6430
6431    #[test]
6432    fn test_component_no_parent_pollution() {
6433        let mut fragments = HashMap::new();
6434        fragments.insert(
6435            "index.html".to_string(),
6436            FragmentList {
6437                fragments: vec![
6438                    WebUIFragment::raw("<parent"),
6439                    WebUIFragment {
6440                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6441                            WebUIFragmentAttribute {
6442                                name: "var".into(),
6443                                value: "var".into(),
6444                                attr_start: true,
6445                                ..Default::default()
6446                            },
6447                        )),
6448                    },
6449                    WebUIFragment::raw(">"),
6450                    WebUIFragment::component("parent"),
6451                    WebUIFragment::raw("</parent>"),
6452                ],
6453
6454                contains_boundary: false,
6455            },
6456        );
6457        fragments.insert(
6458            "parent".to_string(),
6459            FragmentList {
6460                fragments: vec![
6461                    WebUIFragment::raw("Before: "),
6462                    WebUIFragment::signal("var", false),
6463                    WebUIFragment::raw("<child foo"),
6464                    WebUIFragment {
6465                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6466                            WebUIFragmentAttribute {
6467                                name: "var".into(),
6468                                value: "replaced".into(),
6469                                raw_value: true,
6470                                ..Default::default()
6471                            },
6472                        )),
6473                    },
6474                    WebUIFragment::raw(">"),
6475                    WebUIFragment::component("child"),
6476                    WebUIFragment::raw("Label</child>After: "),
6477                    WebUIFragment::signal("var", false),
6478                ],
6479
6480                contains_boundary: false,
6481            },
6482        );
6483        fragments.insert(
6484            "child".to_string(),
6485            FragmentList {
6486                fragments: vec![],
6487                contains_boundary: false,
6488            },
6489        );
6490        let protocol = WebUIProtocol::new(fragments);
6491        let state = test_json!({"var": "original<&"});
6492        let mut writer = TestWriter::new();
6493        handle(
6494            &protocol,
6495            &state,
6496            &RenderOptions::new("index.html", "/"),
6497            &mut writer,
6498        )
6499        .unwrap();
6500        assert_eq!(
6501            writer.get_content(),
6502            "<parent var=\"original&lt;&amp;\">Before: original&lt;&amp;<child foo var=\"replaced\">Label</child>After: original&lt;&amp;</parent>"
6503        );
6504    }
6505
6506    #[test]
6507    fn test_component_boolean_attr_state() {
6508        let mut fragments = HashMap::new();
6509        fragments.insert(
6510            "index.html".to_string(),
6511            FragmentList {
6512                fragments: vec![
6513                    WebUIFragment::raw("<my-comp"),
6514                    WebUIFragment {
6515                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6516                            WebUIFragmentAttribute {
6517                                name: "disabled".into(),
6518                                attr_start: true,
6519                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
6520                                ..Default::default()
6521                            },
6522                        )),
6523                    },
6524                    WebUIFragment::raw(">"),
6525                    WebUIFragment::component("my-comp"),
6526                    WebUIFragment::raw("</my-comp>"),
6527                ],
6528
6529                contains_boundary: false,
6530            },
6531        );
6532        fragments.insert(
6533            "my-comp".to_string(),
6534            FragmentList {
6535                fragments: vec![WebUIFragment::if_cond(
6536                    ConditionExpr::identifier("disabled"),
6537                    "show",
6538                )],
6539                contains_boundary: false,
6540            },
6541        );
6542        fragments.insert(
6543            "show".to_string(),
6544            FragmentList {
6545                fragments: vec![WebUIFragment::raw("disabled!")],
6546                contains_boundary: false,
6547            },
6548        );
6549        let protocol = WebUIProtocol::new(fragments);
6550        let state = test_json!({"isDisabled": true});
6551        let mut writer = TestWriter::new();
6552        handle(
6553            &protocol,
6554            &state,
6555            &RenderOptions::new("index.html", "/"),
6556            &mut writer,
6557        )
6558        .unwrap();
6559        assert_eq!(
6560            writer.get_content(),
6561            "<my-comp disabled>disabled!</my-comp>"
6562        );
6563    }
6564
6565    // ===== HTML Escape Tests (ported from utils.test.js escapeHtml) =====
6566
6567    /// Helper: render a signal value through the handler and return the escaped output.
6568    fn render_signal(value: &str) -> String {
6569        let mut fragments = HashMap::new();
6570        fragments.insert(
6571            "index.html".to_string(),
6572            FragmentList {
6573                fragments: vec![WebUIFragment::signal("v", false)],
6574                contains_boundary: false,
6575            },
6576        );
6577        let protocol = WebUIProtocol::new(fragments);
6578        let state = test_json!({"v": value});
6579        let mut writer = TestWriter::new();
6580        handle(
6581            &protocol,
6582            &state,
6583            &RenderOptions::new("index.html", "/"),
6584            &mut writer,
6585        )
6586        .unwrap();
6587        writer.get_content()
6588    }
6589
6590    #[test]
6591    fn test_escape_ampersand() {
6592        assert_eq!(render_signal("&"), "&amp;");
6593    }
6594
6595    #[test]
6596    fn test_escape_less_than() {
6597        assert_eq!(render_signal("<"), "&lt;");
6598    }
6599
6600    #[test]
6601    fn test_escape_greater_than() {
6602        assert_eq!(render_signal(">"), "&gt;");
6603    }
6604
6605    #[test]
6606    fn test_escape_double_quote() {
6607        assert_eq!(render_signal("\""), "&quot;");
6608    }
6609
6610    #[test]
6611    fn test_escape_single_quote() {
6612        // encode_safe escapes ' as &#x27;
6613        let result = render_signal("'");
6614        assert_eq!(
6615            result, "&#x27;",
6616            "Expected &#x27; for single quote, got: {result}"
6617        );
6618    }
6619
6620    #[test]
6621    fn test_escape_multiple_special_chars() {
6622        let result = render_signal("<script>alert('xss');</script>");
6623        assert!(
6624            result.contains("&lt;") && result.contains("&gt;"),
6625            "Expected escaped HTML, got: {}",
6626            result
6627        );
6628        assert!(
6629            !result.contains("<script>"),
6630            "Should not contain raw <script> tag"
6631        );
6632    }
6633
6634    #[test]
6635    fn test_escape_no_special_chars() {
6636        assert_eq!(render_signal("Hello World"), "Hello World");
6637    }
6638
6639    #[test]
6640    fn test_escape_empty_string() {
6641        assert_eq!(render_signal(""), "");
6642    }
6643
6644    #[test]
6645    fn test_escape_special_at_beginning() {
6646        let result = render_signal("<Hello");
6647        assert!(
6648            result.starts_with("&lt;"),
6649            "Expected &lt; at start, got: {}",
6650            result
6651        );
6652    }
6653
6654    #[test]
6655    fn test_escape_special_at_end() {
6656        let result = render_signal("Hello>");
6657        assert!(
6658            result.ends_with("&gt;"),
6659            "Expected &gt; at end, got: {}",
6660            result
6661        );
6662    }
6663
6664    #[test]
6665    fn test_escape_special_in_middle() {
6666        let result = render_signal("Hel&lo");
6667        assert!(
6668            result.contains("&amp;"),
6669            "Expected &amp; in middle, got: {}",
6670            result
6671        );
6672    }
6673
6674    // ── GROUP 5: Boolean Attribute Edge Cases ─────────────────────────
6675
6676    #[test]
6677    fn test_boolean_attr_truthy_values() {
6678        // checked: 1
6679        {
6680            let mut fragments = HashMap::new();
6681            fragments.insert(
6682                "index.html".to_string(),
6683                FragmentList {
6684                    fragments: vec![
6685                        WebUIFragment::raw("<input"),
6686                        WebUIFragment::attribute_boolean(
6687                            "checked",
6688                            ConditionExpr::identifier("checked"),
6689                        ),
6690                        WebUIFragment::raw(">"),
6691                    ],
6692                    contains_boundary: false,
6693                },
6694            );
6695            let protocol = WebUIProtocol::new(fragments);
6696            let state = test_json!({"checked": 1});
6697            let mut writer = TestWriter::new();
6698            handle(
6699                &protocol,
6700                &state,
6701                &RenderOptions::new("index.html", "/"),
6702                &mut writer,
6703            )
6704            .unwrap();
6705            assert_eq!(writer.get_content(), "<input checked>");
6706        }
6707        // checked: "yes"
6708        {
6709            let mut fragments = HashMap::new();
6710            fragments.insert(
6711                "index.html".to_string(),
6712                FragmentList {
6713                    fragments: vec![
6714                        WebUIFragment::raw("<input"),
6715                        WebUIFragment::attribute_boolean(
6716                            "checked",
6717                            ConditionExpr::identifier("checked"),
6718                        ),
6719                        WebUIFragment::raw(">"),
6720                    ],
6721                    contains_boundary: false,
6722                },
6723            );
6724            let protocol = WebUIProtocol::new(fragments);
6725            let state = test_json!({"checked": "yes"});
6726            let mut writer = TestWriter::new();
6727            handle(
6728                &protocol,
6729                &state,
6730                &RenderOptions::new("index.html", "/"),
6731                &mut writer,
6732            )
6733            .unwrap();
6734            assert_eq!(writer.get_content(), "<input checked>");
6735        }
6736        // checked: {} (empty object is truthy)
6737        {
6738            let mut fragments = HashMap::new();
6739            fragments.insert(
6740                "index.html".to_string(),
6741                FragmentList {
6742                    fragments: vec![
6743                        WebUIFragment::raw("<input"),
6744                        WebUIFragment::attribute_boolean(
6745                            "checked",
6746                            ConditionExpr::identifier("checked"),
6747                        ),
6748                        WebUIFragment::raw(">"),
6749                    ],
6750                    contains_boundary: false,
6751                },
6752            );
6753            let protocol = WebUIProtocol::new(fragments);
6754            let state = test_json!({"checked": {}});
6755            let mut writer = TestWriter::new();
6756            handle(
6757                &protocol,
6758                &state,
6759                &RenderOptions::new("index.html", "/"),
6760                &mut writer,
6761            )
6762            .unwrap();
6763            // Empty object is falsy in this expression evaluator
6764            assert_eq!(writer.get_content(), "<input>");
6765        }
6766        // checked: "false" (string "false" is truthy)
6767        {
6768            let mut fragments = HashMap::new();
6769            fragments.insert(
6770                "index.html".to_string(),
6771                FragmentList {
6772                    fragments: vec![
6773                        WebUIFragment::raw("<input"),
6774                        WebUIFragment::attribute_boolean(
6775                            "checked",
6776                            ConditionExpr::identifier("checked"),
6777                        ),
6778                        WebUIFragment::raw(">"),
6779                    ],
6780                    contains_boundary: false,
6781                },
6782            );
6783            let protocol = WebUIProtocol::new(fragments);
6784            let state = test_json!({"checked": "false"});
6785            let mut writer = TestWriter::new();
6786            handle(
6787                &protocol,
6788                &state,
6789                &RenderOptions::new("index.html", "/"),
6790                &mut writer,
6791            )
6792            .unwrap();
6793            assert_eq!(writer.get_content(), "<input checked>");
6794        }
6795    }
6796
6797    #[test]
6798    fn test_boolean_attr_falsy_values() {
6799        // checked: 0
6800        {
6801            let mut fragments = HashMap::new();
6802            fragments.insert(
6803                "index.html".to_string(),
6804                FragmentList {
6805                    fragments: vec![
6806                        WebUIFragment::raw("<input"),
6807                        WebUIFragment::attribute_boolean(
6808                            "checked",
6809                            ConditionExpr::identifier("checked"),
6810                        ),
6811                        WebUIFragment::raw(">"),
6812                    ],
6813                    contains_boundary: false,
6814                },
6815            );
6816            let protocol = WebUIProtocol::new(fragments);
6817            let state = test_json!({"checked": 0});
6818            let mut writer = TestWriter::new();
6819            handle(
6820                &protocol,
6821                &state,
6822                &RenderOptions::new("index.html", "/"),
6823                &mut writer,
6824            )
6825            .unwrap();
6826            assert_eq!(writer.get_content(), "<input>");
6827        }
6828        // checked: ""
6829        {
6830            let mut fragments = HashMap::new();
6831            fragments.insert(
6832                "index.html".to_string(),
6833                FragmentList {
6834                    fragments: vec![
6835                        WebUIFragment::raw("<input"),
6836                        WebUIFragment::attribute_boolean(
6837                            "checked",
6838                            ConditionExpr::identifier("checked"),
6839                        ),
6840                        WebUIFragment::raw(">"),
6841                    ],
6842                    contains_boundary: false,
6843                },
6844            );
6845            let protocol = WebUIProtocol::new(fragments);
6846            let state = test_json!({"checked": ""});
6847            let mut writer = TestWriter::new();
6848            handle(
6849                &protocol,
6850                &state,
6851                &RenderOptions::new("index.html", "/"),
6852                &mut writer,
6853            )
6854            .unwrap();
6855            assert_eq!(writer.get_content(), "<input>");
6856        }
6857        // checked: false
6858        {
6859            let mut fragments = HashMap::new();
6860            fragments.insert(
6861                "index.html".to_string(),
6862                FragmentList {
6863                    fragments: vec![
6864                        WebUIFragment::raw("<input"),
6865                        WebUIFragment::attribute_boolean(
6866                            "checked",
6867                            ConditionExpr::identifier("checked"),
6868                        ),
6869                        WebUIFragment::raw(">"),
6870                    ],
6871                    contains_boundary: false,
6872                },
6873            );
6874            let protocol = WebUIProtocol::new(fragments);
6875            let state = test_json!({"checked": false});
6876            let mut writer = TestWriter::new();
6877            handle(
6878                &protocol,
6879                &state,
6880                &RenderOptions::new("index.html", "/"),
6881                &mut writer,
6882            )
6883            .unwrap();
6884            assert_eq!(writer.get_content(), "<input>");
6885        }
6886        // no checked key at all
6887        {
6888            let mut fragments = HashMap::new();
6889            fragments.insert(
6890                "index.html".to_string(),
6891                FragmentList {
6892                    fragments: vec![
6893                        WebUIFragment::raw("<input"),
6894                        WebUIFragment::attribute_boolean(
6895                            "checked",
6896                            ConditionExpr::identifier("checked"),
6897                        ),
6898                        WebUIFragment::raw(">"),
6899                    ],
6900                    contains_boundary: false,
6901                },
6902            );
6903            let protocol = WebUIProtocol::new(fragments);
6904            let state = test_json!({});
6905            let mut writer = TestWriter::new();
6906            handle(
6907                &protocol,
6908                &state,
6909                &RenderOptions::new("index.html", "/"),
6910                &mut writer,
6911            )
6912            .unwrap();
6913            assert_eq!(writer.get_content(), "<input>");
6914        }
6915    }
6916
6917    #[test]
6918    fn test_boolean_attr_expression_true() {
6919        let mut fragments = HashMap::new();
6920        fragments.insert(
6921            "index.html".to_string(),
6922            FragmentList {
6923                fragments: vec![
6924                    WebUIFragment::raw("<button"),
6925                    WebUIFragment::attribute_boolean(
6926                        "disabled",
6927                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6928                    ),
6929                    WebUIFragment::raw(">Click</button>"),
6930                ],
6931                contains_boundary: false,
6932            },
6933        );
6934        let protocol = WebUIProtocol::new(fragments);
6935        let state = test_json!({"itemCount": 5});
6936        let mut writer = TestWriter::new();
6937        handle(
6938            &protocol,
6939            &state,
6940            &RenderOptions::new("index.html", "/"),
6941            &mut writer,
6942        )
6943        .unwrap();
6944        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
6945    }
6946
6947    #[test]
6948    fn test_boolean_attr_expression_false() {
6949        let mut fragments = HashMap::new();
6950        fragments.insert(
6951            "index.html".to_string(),
6952            FragmentList {
6953                fragments: vec![
6954                    WebUIFragment::raw("<button"),
6955                    WebUIFragment::attribute_boolean(
6956                        "disabled",
6957                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6958                    ),
6959                    WebUIFragment::raw(">Click</button>"),
6960                ],
6961                contains_boundary: false,
6962            },
6963        );
6964        let protocol = WebUIProtocol::new(fragments);
6965        let state = test_json!({"itemCount": 3});
6966        let mut writer = TestWriter::new();
6967        handle(
6968            &protocol,
6969            &state,
6970            &RenderOptions::new("index.html", "/"),
6971            &mut writer,
6972        )
6973        .unwrap();
6974        assert_eq!(writer.get_content(), "<button>Click</button>");
6975    }
6976
6977    // ── GROUP 6: Mixed Attributes ─────────────────────────────────────
6978
6979    #[test]
6980    fn test_nested_component_attr_capture() {
6981        let mut fragments = HashMap::new();
6982        fragments.insert(
6983            "index.html".to_string(),
6984            FragmentList {
6985                fragments: vec![
6986                    WebUIFragment::raw("<parent-component"),
6987                    WebUIFragment {
6988                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6989                            WebUIFragmentAttribute {
6990                                name: "title".into(),
6991                                template: "parent-title".into(),
6992                                attr_start: true,
6993                                ..Default::default()
6994                            },
6995                        )),
6996                    },
6997                    WebUIFragment::raw(">"),
6998                    WebUIFragment::component("parent-component"),
6999                    WebUIFragment::raw("</parent-component>"),
7000                ],
7001
7002                contains_boundary: false,
7003            },
7004        );
7005        fragments.insert(
7006            "parent-title".to_string(),
7007            FragmentList {
7008                fragments: vec![
7009                    WebUIFragment::raw("Hello "),
7010                    WebUIFragment::signal("who", false),
7011                ],
7012                contains_boundary: false,
7013            },
7014        );
7015        fragments.insert(
7016            "parent-component".to_string(),
7017            FragmentList {
7018                fragments: vec![
7019                    WebUIFragment::raw("<child-component"),
7020                    WebUIFragment {
7021                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7022                            WebUIFragmentAttribute {
7023                                name: "title".into(),
7024                                template: "child-title".into(),
7025                                attr_start: true,
7026                                ..Default::default()
7027                            },
7028                        )),
7029                    },
7030                    WebUIFragment::raw(">"),
7031                    WebUIFragment::component("child-component"),
7032                    WebUIFragment::raw("</child-component>"),
7033                ],
7034
7035                contains_boundary: false,
7036            },
7037        );
7038        fragments.insert(
7039            "child-title".to_string(),
7040            FragmentList {
7041                fragments: vec![
7042                    WebUIFragment::raw("Child of "),
7043                    WebUIFragment::signal("title", false),
7044                ],
7045                contains_boundary: false,
7046            },
7047        );
7048        fragments.insert(
7049            "child-component".to_string(),
7050            FragmentList {
7051                fragments: vec![
7052                    WebUIFragment::raw("<span>"),
7053                    WebUIFragment::signal("title", false),
7054                    WebUIFragment::raw("</span>"),
7055                ],
7056                contains_boundary: false,
7057            },
7058        );
7059        let protocol = WebUIProtocol::new(fragments);
7060        let state = test_json!({"who": "<world>"});
7061        let mut writer = TestWriter::new();
7062        handle(
7063            &protocol,
7064            &state,
7065            &RenderOptions::new("index.html", "/"),
7066            &mut writer,
7067        )
7068        .unwrap();
7069        assert_eq!(
7070            writer.get_content(),
7071            "<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>"
7072        );
7073    }
7074
7075    #[test]
7076    fn test_grandchild_attr_propagation() {
7077        let mut fragments = HashMap::new();
7078        fragments.insert(
7079            "index.html".to_string(),
7080            FragmentList {
7081                fragments: vec![
7082                    WebUIFragment::raw("<parent-component"),
7083                    WebUIFragment {
7084                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7085                            WebUIFragmentAttribute {
7086                                name: "title".into(),
7087                                template: "p-title".into(),
7088                                attr_start: true,
7089                                ..Default::default()
7090                            },
7091                        )),
7092                    },
7093                    WebUIFragment::raw(">"),
7094                    WebUIFragment::component("parent-component"),
7095                    WebUIFragment::raw("</parent-component>"),
7096                ],
7097
7098                contains_boundary: false,
7099            },
7100        );
7101        fragments.insert(
7102            "p-title".to_string(),
7103            FragmentList {
7104                fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
7105                contains_boundary: false,
7106            },
7107        );
7108        fragments.insert(
7109            "parent-component".to_string(),
7110            FragmentList {
7111                fragments: vec![
7112                    WebUIFragment::raw("<child-component"),
7113                    WebUIFragment {
7114                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7115                            WebUIFragmentAttribute {
7116                                name: "title".into(),
7117                                template: "c-title".into(),
7118                                attr_start: true,
7119                                ..Default::default()
7120                            },
7121                        )),
7122                    },
7123                    WebUIFragment::raw(">"),
7124                    WebUIFragment::component("child-component"),
7125                    WebUIFragment::raw("</child-component>"),
7126                ],
7127
7128                contains_boundary: false,
7129            },
7130        );
7131        fragments.insert(
7132            "c-title".to_string(),
7133            FragmentList {
7134                fragments: vec![
7135                    WebUIFragment::raw("C("),
7136                    WebUIFragment::signal("title", false),
7137                    WebUIFragment::raw(")-"),
7138                    WebUIFragment::signal("cExtra", false),
7139                ],
7140                contains_boundary: false,
7141            },
7142        );
7143        fragments.insert(
7144            "child-component".to_string(),
7145            FragmentList {
7146                fragments: vec![
7147                    WebUIFragment::raw("<grandchild-component"),
7148                    WebUIFragment {
7149                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7150                            WebUIFragmentAttribute {
7151                                name: "title".into(),
7152                                value: "title".into(),
7153                                attr_start: true,
7154                                ..Default::default()
7155                            },
7156                        )),
7157                    },
7158                    WebUIFragment::raw(">"),
7159                    WebUIFragment::component("grandchild-component"),
7160                    WebUIFragment::raw("</grandchild-component>"),
7161                ],
7162
7163                contains_boundary: false,
7164            },
7165        );
7166        fragments.insert(
7167            "grandchild-component".to_string(),
7168            FragmentList {
7169                fragments: vec![
7170                    WebUIFragment::raw("<span>"),
7171                    WebUIFragment::signal("title", false),
7172                    WebUIFragment::raw("</span>"),
7173                ],
7174                contains_boundary: false,
7175            },
7176        );
7177        let protocol = WebUIProtocol::new(fragments);
7178        let state = test_json!({"p": "<p>", "cExtra": "x&y"});
7179        let mut writer = TestWriter::new();
7180        handle(
7181            &protocol,
7182            &state,
7183            &RenderOptions::new("index.html", "/"),
7184            &mut writer,
7185        )
7186        .unwrap();
7187        assert_eq!(
7188            writer.get_content(),
7189            "<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>"
7190        );
7191    }
7192
7193    #[test]
7194    fn test_for_loop_component_attr() {
7195        let mut fragments = HashMap::new();
7196        fragments.insert(
7197            "index.html".to_string(),
7198            FragmentList {
7199                fragments: vec![
7200                    WebUIFragment::raw("<parent-component"),
7201                    WebUIFragment {
7202                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7203                            WebUIFragmentAttribute {
7204                                name: "title".into(),
7205                                template: "parent-title-loop".into(),
7206                                attr_start: true,
7207                                ..Default::default()
7208                            },
7209                        )),
7210                    },
7211                    WebUIFragment::raw(">"),
7212                    WebUIFragment::component("parent-component"),
7213                    WebUIFragment::raw("</parent-component>"),
7214                ],
7215
7216                contains_boundary: false,
7217            },
7218        );
7219        fragments.insert(
7220            "parent-title-loop".to_string(),
7221            FragmentList {
7222                fragments: vec![
7223                    WebUIFragment::raw("Parent:"),
7224                    WebUIFragment::signal("who", false),
7225                ],
7226                contains_boundary: false,
7227            },
7228        );
7229        fragments.insert(
7230            "parent-component".to_string(),
7231            FragmentList {
7232                fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
7233                contains_boundary: false,
7234            },
7235        );
7236        fragments.insert(
7237            "child-loop".to_string(),
7238            FragmentList {
7239                fragments: vec![
7240                    WebUIFragment::raw("<child-component"),
7241                    WebUIFragment {
7242                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7243                            WebUIFragmentAttribute {
7244                                name: "title".into(),
7245                                template: "child-title-loop".into(),
7246                                attr_start: true,
7247                                ..Default::default()
7248                            },
7249                        )),
7250                    },
7251                    WebUIFragment::raw(">"),
7252                    WebUIFragment::component("child-component"),
7253                    WebUIFragment::raw("</child-component>"),
7254                ],
7255
7256                contains_boundary: false,
7257            },
7258        );
7259        fragments.insert(
7260            "child-title-loop".to_string(),
7261            FragmentList {
7262                fragments: vec![
7263                    WebUIFragment::raw("Hi "),
7264                    WebUIFragment::signal("item.name", false),
7265                    WebUIFragment::raw(" / "),
7266                    WebUIFragment::signal("title", false),
7267                ],
7268                contains_boundary: false,
7269            },
7270        );
7271        fragments.insert(
7272            "child-component".to_string(),
7273            FragmentList {
7274                fragments: vec![
7275                    WebUIFragment::raw("<span>"),
7276                    WebUIFragment::signal("title", false),
7277                    WebUIFragment::raw("</span>"),
7278                ],
7279                contains_boundary: false,
7280            },
7281        );
7282        let protocol = WebUIProtocol::new(fragments);
7283        let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
7284        let mut writer = TestWriter::new();
7285        handle(
7286            &protocol,
7287            &state,
7288            &RenderOptions::new("index.html", "/"),
7289            &mut writer,
7290        )
7291        .unwrap();
7292        assert_eq!(
7293            writer.get_content(),
7294            "<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>"
7295        );
7296    }
7297
7298    #[test]
7299    fn test_multiple_template_attrs() {
7300        let mut fragments = HashMap::new();
7301        fragments.insert(
7302            "index.html".to_string(),
7303            FragmentList {
7304                fragments: vec![
7305                    WebUIFragment::raw("<my-component"),
7306                    WebUIFragment {
7307                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7308                            WebUIFragmentAttribute {
7309                                name: "title".into(),
7310                                template: "attr-title".into(),
7311                                attr_start: true,
7312                                ..Default::default()
7313                            },
7314                        )),
7315                    },
7316                    WebUIFragment {
7317                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7318                            WebUIFragmentAttribute {
7319                                name: "data-title".into(),
7320                                template: "attr-data-title".into(),
7321                                attr_start: false,
7322                                ..Default::default()
7323                            },
7324                        )),
7325                    },
7326                    WebUIFragment {
7327                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7328                            WebUIFragmentAttribute {
7329                                name: "aria-label".into(),
7330                                template: "attr-aria-label".into(),
7331                                attr_start: false,
7332                                ..Default::default()
7333                            },
7334                        )),
7335                    },
7336                    WebUIFragment::raw(">"),
7337                    WebUIFragment::component("my-component"),
7338                    WebUIFragment::raw("</my-component>"),
7339                ],
7340
7341                contains_boundary: false,
7342            },
7343        );
7344        fragments.insert(
7345            "attr-title".to_string(),
7346            FragmentList {
7347                fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
7348                contains_boundary: false,
7349            },
7350        );
7351        fragments.insert(
7352            "attr-data-title".to_string(),
7353            FragmentList {
7354                fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
7355                contains_boundary: false,
7356            },
7357        );
7358        fragments.insert(
7359            "attr-aria-label".to_string(),
7360            FragmentList {
7361                fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
7362                contains_boundary: false,
7363            },
7364        );
7365        fragments.insert(
7366            "my-component".to_string(),
7367            FragmentList {
7368                fragments: vec![
7369                    WebUIFragment::raw("<span>"),
7370                    WebUIFragment::signal("title", false),
7371                    WebUIFragment::raw("|"),
7372                    WebUIFragment::signal("dataTitle", false),
7373                    WebUIFragment::raw("|"),
7374                    WebUIFragment::signal("ariaLabel", false),
7375                    WebUIFragment::raw("</span>"),
7376                ],
7377                contains_boundary: false,
7378            },
7379        );
7380        let protocol = WebUIProtocol::new(fragments);
7381        let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
7382        let mut writer = TestWriter::new();
7383        handle(
7384            &protocol,
7385            &state,
7386            &RenderOptions::new("index.html", "/"),
7387            &mut writer,
7388        )
7389        .unwrap();
7390        assert_eq!(
7391            writer.get_content(),
7392            "<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>"
7393        );
7394    }
7395
7396    #[test]
7397    fn test_attr_priority_over_global() {
7398        let mut fragments = HashMap::new();
7399        fragments.insert(
7400            "index.html".to_string(),
7401            FragmentList {
7402                fragments: vec![
7403                    WebUIFragment::raw("<my-component"),
7404                    WebUIFragment {
7405                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7406                            WebUIFragmentAttribute {
7407                                name: "title".into(),
7408                                value: "Attribute Title".into(),
7409                                raw_value: true,
7410                                attr_start: true,
7411                                ..Default::default()
7412                            },
7413                        )),
7414                    },
7415                    WebUIFragment::raw(">"),
7416                    WebUIFragment::component("my-component"),
7417                    WebUIFragment::raw("</my-component>"),
7418                ],
7419
7420                contains_boundary: false,
7421            },
7422        );
7423        fragments.insert(
7424            "my-component".to_string(),
7425            FragmentList {
7426                fragments: vec![
7427                    WebUIFragment::raw("<span>"),
7428                    WebUIFragment::signal("title", false),
7429                    WebUIFragment::raw("</span>"),
7430                ],
7431                contains_boundary: false,
7432            },
7433        );
7434        let protocol = WebUIProtocol::new(fragments);
7435        let state = test_json!({"title": "Global Title"});
7436        let mut writer = TestWriter::new();
7437        handle(
7438            &protocol,
7439            &state,
7440            &RenderOptions::new("index.html", "/"),
7441            &mut writer,
7442        )
7443        .unwrap();
7444        assert_eq!(
7445            writer.get_content(),
7446            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
7447        );
7448    }
7449
7450    #[test]
7451    fn test_attr_priority_over_local_and_global() {
7452        let mut fragments = HashMap::new();
7453        fragments.insert(
7454            "index.html".to_string(),
7455            FragmentList {
7456                fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
7457                contains_boundary: false,
7458            },
7459        );
7460        fragments.insert(
7461            "loop".to_string(),
7462            FragmentList {
7463                fragments: vec![
7464                    WebUIFragment::raw("<my-component"),
7465                    WebUIFragment {
7466                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7467                            WebUIFragmentAttribute {
7468                                name: "title".into(),
7469                                value: "Attribute Title".into(),
7470                                raw_value: true,
7471                                attr_start: true,
7472                                ..Default::default()
7473                            },
7474                        )),
7475                    },
7476                    WebUIFragment::raw(">"),
7477                    WebUIFragment::component("my-component"),
7478                    WebUIFragment::raw("</my-component>"),
7479                ],
7480
7481                contains_boundary: false,
7482            },
7483        );
7484        fragments.insert(
7485            "my-component".to_string(),
7486            FragmentList {
7487                fragments: vec![
7488                    WebUIFragment::raw("<span>"),
7489                    WebUIFragment::signal("title", false),
7490                    WebUIFragment::raw("</span>"),
7491                ],
7492                contains_boundary: false,
7493            },
7494        );
7495        let protocol = WebUIProtocol::new(fragments);
7496        let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
7497        let mut writer = TestWriter::new();
7498        handle(
7499            &protocol,
7500            &state,
7501            &RenderOptions::new("index.html", "/"),
7502            &mut writer,
7503        )
7504        .unwrap();
7505        assert_eq!(
7506            writer.get_content(),
7507            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
7508        );
7509    }
7510
7511    #[test]
7512    fn test_boolean_attr_first_component_attr() {
7513        let mut fragments = HashMap::new();
7514        fragments.insert(
7515            "index.html".to_string(),
7516            FragmentList {
7517                fragments: vec![
7518                    WebUIFragment::raw("<my-component"),
7519                    WebUIFragment {
7520                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7521                            WebUIFragmentAttribute {
7522                                name: "disabled".into(),
7523                                attr_start: true,
7524                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7525                                ..Default::default()
7526                            },
7527                        )),
7528                    },
7529                    WebUIFragment {
7530                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7531                            WebUIFragmentAttribute {
7532                                name: "label".into(),
7533                                value: "Component Label".into(),
7534                                raw_value: true,
7535                                attr_start: false,
7536                                ..Default::default()
7537                            },
7538                        )),
7539                    },
7540                    WebUIFragment::raw(">"),
7541                    WebUIFragment::component("my-component"),
7542                    WebUIFragment::raw("</my-component>"),
7543                ],
7544
7545                contains_boundary: false,
7546            },
7547        );
7548        fragments.insert(
7549            "my-component".to_string(),
7550            FragmentList {
7551                fragments: vec![
7552                    WebUIFragment::if_cond(
7553                        ConditionExpr::identifier("disabled"),
7554                        "disabledTemplate",
7555                    ),
7556                    WebUIFragment::raw("<span>"),
7557                    WebUIFragment::signal("label", false),
7558                    WebUIFragment::raw("</span>"),
7559                ],
7560                contains_boundary: false,
7561            },
7562        );
7563        fragments.insert(
7564            "disabledTemplate".to_string(),
7565            FragmentList {
7566                fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
7567                contains_boundary: false,
7568            },
7569        );
7570        let protocol = WebUIProtocol::new(fragments);
7571        let state = test_json!({"isDisabled": true});
7572        let mut writer = TestWriter::new();
7573        handle(
7574            &protocol,
7575            &state,
7576            &RenderOptions::new("index.html", "/"),
7577            &mut writer,
7578        )
7579        .unwrap();
7580        assert_eq!(
7581            writer.get_content(),
7582            "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
7583        );
7584    }
7585
7586    #[test]
7587    fn test_hyphenated_attr_camelcase() {
7588        let mut fragments = HashMap::new();
7589        fragments.insert(
7590            "index.html".to_string(),
7591            FragmentList {
7592                fragments: vec![
7593                    WebUIFragment::raw("<my-component"),
7594                    WebUIFragment {
7595                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7596                            WebUIFragmentAttribute {
7597                                name: "key-hyphen".into(),
7598                                value: "Local Value".into(),
7599                                raw_value: true,
7600                                attr_start: true,
7601                                ..Default::default()
7602                            },
7603                        )),
7604                    },
7605                    WebUIFragment::raw(">"),
7606                    WebUIFragment::component("my-component"),
7607                    WebUIFragment::raw("</my-component>"),
7608                ],
7609
7610                contains_boundary: false,
7611            },
7612        );
7613        fragments.insert(
7614            "my-component".to_string(),
7615            FragmentList {
7616                fragments: vec![
7617                    WebUIFragment::raw("<span>"),
7618                    WebUIFragment::signal("keyHyphen", false),
7619                    WebUIFragment::raw("</span>"),
7620                ],
7621                contains_boundary: false,
7622            },
7623        );
7624        let protocol = WebUIProtocol::new(fragments);
7625        let state = test_json!({"keyHyphen": "Global Value"});
7626        let mut writer = TestWriter::new();
7627        handle(
7628            &protocol,
7629            &state,
7630            &RenderOptions::new("index.html", "/"),
7631            &mut writer,
7632        )
7633        .unwrap();
7634        assert_eq!(
7635            writer.get_content(),
7636            "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
7637        );
7638    }
7639
7640    #[test]
7641    fn test_skipped_component_attrs() {
7642        // Skipped attributes: class, style, role, data-*, aria-*
7643        // Plus framework-specific prefixes/names that the parser marks with attr_skip.
7644        // These render on the HTML element but are NOT passed into component attribute state.
7645        let mut fragments = HashMap::new();
7646        fragments.insert(
7647            "index.html".to_string(),
7648            FragmentList {
7649                fragments: vec![
7650                    WebUIFragment::raw("<test-component"),
7651                    // Skipped: class
7652                    WebUIFragment {
7653                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7654                            WebUIFragmentAttribute {
7655                                name: "class".into(),
7656                                value: "skippedClass".into(),
7657                                attr_start: true,
7658                                attr_skip: true,
7659                                ..Default::default()
7660                            },
7661                        )),
7662                    },
7663                    // Skipped: style
7664                    WebUIFragment {
7665                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7666                            WebUIFragmentAttribute {
7667                                name: "style".into(),
7668                                value: "skippedStyle".into(),
7669                                attr_skip: true,
7670                                ..Default::default()
7671                            },
7672                        )),
7673                    },
7674                    // Skipped: role
7675                    WebUIFragment {
7676                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7677                            WebUIFragmentAttribute {
7678                                name: "role".into(),
7679                                value: "skippedRole".into(),
7680                                attr_skip: true,
7681                                ..Default::default()
7682                            },
7683                        )),
7684                    },
7685                    // Skipped: data-testid (data-* prefix)
7686                    WebUIFragment {
7687                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7688                            WebUIFragmentAttribute {
7689                                name: "data-testid".into(),
7690                                value: "skippedDataTestid".into(),
7691                                attr_skip: true,
7692                                ..Default::default()
7693                            },
7694                        )),
7695                    },
7696                    // Skipped: aria-label (aria-* prefix)
7697                    WebUIFragment {
7698                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7699                            WebUIFragmentAttribute {
7700                                name: "aria-label".into(),
7701                                value: "skippedAriaLabel".into(),
7702                                attr_skip: true,
7703                                ..Default::default()
7704                            },
7705                        )),
7706                    },
7707                    // NOT skipped: title
7708                    WebUIFragment {
7709                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7710                            WebUIFragmentAttribute {
7711                                name: "title".into(),
7712                                value: "title".into(),
7713                                ..Default::default()
7714                            },
7715                        )),
7716                    },
7717                    WebUIFragment::raw(">"),
7718                    WebUIFragment::component("test-component"),
7719                    WebUIFragment::raw("</test-component>"),
7720                ],
7721
7722                contains_boundary: false,
7723            },
7724        );
7725        fragments.insert(
7726            "test-component".to_string(),
7727            FragmentList {
7728                fragments: vec![
7729                    WebUIFragment::raw("<span>"),
7730                    WebUIFragment::signal("title", false),
7731                    WebUIFragment::raw("-"),
7732                    WebUIFragment::signal("class", false),
7733                    WebUIFragment::raw("-"),
7734                    WebUIFragment::signal("style", false),
7735                    WebUIFragment::raw("-"),
7736                    WebUIFragment::signal("role", false),
7737                    WebUIFragment::raw("-"),
7738                    WebUIFragment::signal("dataTestid", false),
7739                    WebUIFragment::raw("-"),
7740                    WebUIFragment::signal("ariaLabel", false),
7741                    WebUIFragment::raw("</span>"),
7742                ],
7743                contains_boundary: false,
7744            },
7745        );
7746        let protocol = WebUIProtocol::new(fragments);
7747        let state = test_json!({
7748            "title": "Hello",
7749            "skippedClass": "my-class",
7750            "skippedStyle": "color:red",
7751            "skippedRole": "button",
7752            "skippedDataTestid": "test-id",
7753            "skippedAriaLabel": "label-text"
7754        });
7755        let mut writer = TestWriter::new();
7756        handle(
7757            &protocol,
7758            &state,
7759            &RenderOptions::new("index.html", "/"),
7760            &mut writer,
7761        )
7762        .unwrap();
7763        // Skipped attrs render on the element but their values are NOT accessible inside the component.
7764        // The component's signals for skipped attrs resolve to empty strings.
7765        // Only "title" (non-skipped) is accessible.
7766        assert_eq!(
7767            writer.get_content(),
7768            "<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>"
7769        );
7770    }
7771
7772    // ── GROUP 7: Attribute Inheritance ─────────────────────────────────
7773
7774    #[test]
7775    fn test_attr_inherit_parent_to_child() {
7776        let mut fragments = HashMap::new();
7777        fragments.insert(
7778            "index.html".to_string(),
7779            FragmentList {
7780                fragments: vec![
7781                    WebUIFragment::raw("<parent-component"),
7782                    WebUIFragment {
7783                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7784                            WebUIFragmentAttribute {
7785                                name: "title".into(),
7786                                value: "Parent Title".into(),
7787                                raw_value: true,
7788                                attr_start: true,
7789                                ..Default::default()
7790                            },
7791                        )),
7792                    },
7793                    WebUIFragment::raw(">"),
7794                    WebUIFragment::component("parent-component"),
7795                    WebUIFragment::raw("</parent-component>"),
7796                ],
7797
7798                contains_boundary: false,
7799            },
7800        );
7801        fragments.insert(
7802            "parent-component".to_string(),
7803            FragmentList {
7804                fragments: vec![
7805                    WebUIFragment::raw("<h1>"),
7806                    WebUIFragment::signal("title", false),
7807                    WebUIFragment::raw("</h1><child-component"),
7808                    WebUIFragment {
7809                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7810                            WebUIFragmentAttribute {
7811                                name: "title".into(),
7812                                value: "title".into(),
7813                                attr_start: true,
7814                                ..Default::default()
7815                            },
7816                        )),
7817                    },
7818                    WebUIFragment::raw(">"),
7819                    WebUIFragment::component("child-component"),
7820                    WebUIFragment::raw("</child-component>"),
7821                ],
7822
7823                contains_boundary: false,
7824            },
7825        );
7826        fragments.insert(
7827            "child-component".to_string(),
7828            FragmentList {
7829                fragments: vec![
7830                    WebUIFragment::raw("<h2>"),
7831                    WebUIFragment::signal("title", false),
7832                    WebUIFragment::raw("</h2>"),
7833                ],
7834                contains_boundary: false,
7835            },
7836        );
7837        let protocol = WebUIProtocol::new(fragments);
7838        let state = test_json!({});
7839        let mut writer = TestWriter::new();
7840        handle(
7841            &protocol,
7842            &state,
7843            &RenderOptions::new("index.html", "/"),
7844            &mut writer,
7845        )
7846        .unwrap();
7847        assert_eq!(
7848            writer.get_content(),
7849            "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
7850        );
7851    }
7852
7853    #[test]
7854    fn test_attr_inherit_deep() {
7855        let mut fragments = HashMap::new();
7856        fragments.insert(
7857            "index.html".to_string(),
7858            FragmentList {
7859                fragments: vec![
7860                    WebUIFragment::raw("<parent-component"),
7861                    WebUIFragment {
7862                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7863                            WebUIFragmentAttribute {
7864                                name: "title".into(),
7865                                value: "Parent Title".into(),
7866                                raw_value: true,
7867                                attr_start: true,
7868                                ..Default::default()
7869                            },
7870                        )),
7871                    },
7872                    WebUIFragment::raw(">"),
7873                    WebUIFragment::component("parent-component"),
7874                    WebUIFragment::raw("</parent-component>"),
7875                ],
7876
7877                contains_boundary: false,
7878            },
7879        );
7880        fragments.insert(
7881            "parent-component".to_string(),
7882            FragmentList {
7883                fragments: vec![
7884                    WebUIFragment::raw("<child-component"),
7885                    WebUIFragment {
7886                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7887                            WebUIFragmentAttribute {
7888                                name: "title".into(),
7889                                value: "Child Title".into(),
7890                                raw_value: true,
7891                                attr_start: true,
7892                                ..Default::default()
7893                            },
7894                        )),
7895                    },
7896                    WebUIFragment::raw(">"),
7897                    WebUIFragment::component("child-component"),
7898                    WebUIFragment::raw("</child-component>"),
7899                ],
7900
7901                contains_boundary: false,
7902            },
7903        );
7904        fragments.insert(
7905            "child-component".to_string(),
7906            FragmentList {
7907                fragments: vec![
7908                    WebUIFragment::raw("<grandchild-component"),
7909                    WebUIFragment {
7910                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7911                            WebUIFragmentAttribute {
7912                                name: "title".into(),
7913                                value: "title".into(),
7914                                attr_start: true,
7915                                ..Default::default()
7916                            },
7917                        )),
7918                    },
7919                    WebUIFragment::raw(">"),
7920                    WebUIFragment::component("grandchild-component"),
7921                    WebUIFragment::raw("</grandchild-component>"),
7922                ],
7923
7924                contains_boundary: false,
7925            },
7926        );
7927        fragments.insert(
7928            "grandchild-component".to_string(),
7929            FragmentList {
7930                fragments: vec![
7931                    WebUIFragment::raw("<h3>"),
7932                    WebUIFragment::signal("title", false),
7933                    WebUIFragment::raw("</h3>"),
7934                ],
7935                contains_boundary: false,
7936            },
7937        );
7938        let protocol = WebUIProtocol::new(fragments);
7939        let state = test_json!({});
7940        let mut writer = TestWriter::new();
7941        handle(
7942            &protocol,
7943            &state,
7944            &RenderOptions::new("index.html", "/"),
7945            &mut writer,
7946        )
7947        .unwrap();
7948        assert_eq!(
7949            writer.get_content(),
7950            "<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>"
7951        );
7952    }
7953
7954    #[test]
7955    fn test_complex_attr_access() {
7956        let mut fragments = HashMap::new();
7957        fragments.insert(
7958            "index.html".to_string(),
7959            FragmentList {
7960                fragments: vec![
7961                    WebUIFragment::raw("<my-component"),
7962                    WebUIFragment {
7963                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7964                            WebUIFragmentAttribute {
7965                                name: ":item".into(),
7966                                value: "complexItem".into(),
7967                                attr_start: true,
7968                                complex: true,
7969                                ..Default::default()
7970                            },
7971                        )),
7972                    },
7973                    WebUIFragment::raw(">"),
7974                    WebUIFragment::component("my-component"),
7975                    WebUIFragment::raw("</my-component>"),
7976                ],
7977
7978                contains_boundary: false,
7979            },
7980        );
7981        fragments.insert(
7982            "my-component".to_string(),
7983            FragmentList {
7984                fragments: vec![
7985                    WebUIFragment::raw("<span>"),
7986                    WebUIFragment::signal("item.foo", false),
7987                    WebUIFragment::raw("</span><p>"),
7988                    WebUIFragment::signal("item.bar", false),
7989                    WebUIFragment::raw("</p>"),
7990                ],
7991                contains_boundary: false,
7992            },
7993        );
7994        let protocol = WebUIProtocol::new(fragments);
7995        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
7996        let mut writer = TestWriter::new();
7997        handle(
7998            &protocol,
7999            &state,
8000            &RenderOptions::new("index.html", "/"),
8001            &mut writer,
8002        )
8003        .unwrap();
8004        assert_eq!(
8005            writer.get_content(),
8006            "<my-component><span>1</span><p>true</p></my-component>"
8007        );
8008    }
8009
8010    #[test]
8011    fn test_complex_attr_for_loop() {
8012        let mut fragments = HashMap::new();
8013        fragments.insert(
8014            "index.html".to_string(),
8015            FragmentList {
8016                fragments: vec![WebUIFragment::for_loop(
8017                    "item",
8018                    "list.items",
8019                    "listTemplate",
8020                )],
8021                contains_boundary: false,
8022            },
8023        );
8024        fragments.insert(
8025            "listTemplate".to_string(),
8026            FragmentList {
8027                fragments: vec![
8028                    WebUIFragment {
8029                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8030                            WebUIFragmentAttribute {
8031                                name: ":item".into(),
8032                                value: "item".into(),
8033                                attr_start: true,
8034                                complex: true,
8035                                ..Default::default()
8036                            },
8037                        )),
8038                    },
8039                    WebUIFragment::component("item_component"),
8040                ],
8041
8042                contains_boundary: false,
8043            },
8044        );
8045        fragments.insert(
8046            "item_component".to_string(),
8047            FragmentList {
8048                fragments: vec![
8049                    WebUIFragment::raw("<span>"),
8050                    WebUIFragment::signal("item.name", false),
8051                    WebUIFragment::raw("</span>"),
8052                ],
8053                contains_boundary: false,
8054            },
8055        );
8056        let protocol = WebUIProtocol::new(fragments);
8057        let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
8058        let mut writer = TestWriter::new();
8059        handle(
8060            &protocol,
8061            &state,
8062            &RenderOptions::new("index.html", "/"),
8063            &mut writer,
8064        )
8065        .unwrap();
8066        assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
8067    }
8068
8069    #[test]
8070    fn test_complex_attr_nested_for() {
8071        let mut fragments = HashMap::new();
8072        fragments.insert(
8073            "index.html".to_string(),
8074            FragmentList {
8075                fragments: vec![WebUIFragment::for_loop(
8076                    "outer",
8077                    "data.outer",
8078                    "outerTemplate",
8079                )],
8080                contains_boundary: false,
8081            },
8082        );
8083        fragments.insert(
8084            "outerTemplate".to_string(),
8085            FragmentList {
8086                fragments: vec![WebUIFragment::for_loop(
8087                    "middle",
8088                    "outer.middle",
8089                    "middleTemplate",
8090                )],
8091                contains_boundary: false,
8092            },
8093        );
8094        fragments.insert(
8095            "middleTemplate".to_string(),
8096            FragmentList {
8097                fragments: vec![WebUIFragment::for_loop(
8098                    "inner",
8099                    "middle.inner",
8100                    "innerTemplate",
8101                )],
8102                contains_boundary: false,
8103            },
8104        );
8105        fragments.insert(
8106            "innerTemplate".to_string(),
8107            FragmentList {
8108                fragments: vec![
8109                    WebUIFragment::raw("<card"),
8110                    WebUIFragment {
8111                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8112                            WebUIFragmentAttribute {
8113                                name: ":outer".into(),
8114                                value: "outer".into(),
8115                                attr_start: true,
8116                                complex: true,
8117                                ..Default::default()
8118                            },
8119                        )),
8120                    },
8121                    WebUIFragment {
8122                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8123                            WebUIFragmentAttribute {
8124                                name: ":middle".into(),
8125                                value: "middle".into(),
8126                                attr_start: false,
8127                                complex: true,
8128                                ..Default::default()
8129                            },
8130                        )),
8131                    },
8132                    WebUIFragment {
8133                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8134                            WebUIFragmentAttribute {
8135                                name: ":inner".into(),
8136                                value: "inner".into(),
8137                                attr_start: false,
8138                                complex: true,
8139                                ..Default::default()
8140                            },
8141                        )),
8142                    },
8143                    WebUIFragment::raw(">"),
8144                    WebUIFragment::component("card_component"),
8145                    WebUIFragment::raw("</card>"),
8146                ],
8147
8148                contains_boundary: false,
8149            },
8150        );
8151        fragments.insert(
8152            "card_component".to_string(),
8153            FragmentList {
8154                fragments: vec![
8155                    WebUIFragment::raw("<p>"),
8156                    WebUIFragment::signal("outer.label", false),
8157                    WebUIFragment::raw(" / "),
8158                    WebUIFragment::signal("middle.label", false),
8159                    WebUIFragment::raw(" / "),
8160                    WebUIFragment::signal("inner.label", false),
8161                    WebUIFragment::raw("</p>"),
8162                ],
8163                contains_boundary: false,
8164            },
8165        );
8166        let protocol = WebUIProtocol::new(fragments);
8167        let state = test_json!({"data": {"outer": [
8168            {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
8169            {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
8170        ]}});
8171        let mut writer = TestWriter::new();
8172        handle(
8173            &protocol,
8174            &state,
8175            &RenderOptions::new("index.html", "/"),
8176            &mut writer,
8177        )
8178        .unwrap();
8179        assert_eq!(
8180            writer.get_content(),
8181            "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
8182        );
8183    }
8184
8185    // ── GROUP 8: Boolean Component State ──────────────────────────────
8186
8187    #[test]
8188    fn test_bool_component_state_true() {
8189        let mut fragments = HashMap::new();
8190        fragments.insert(
8191            "index.html".to_string(),
8192            FragmentList {
8193                fragments: vec![
8194                    WebUIFragment::raw("<my-component"),
8195                    WebUIFragment {
8196                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8197                            WebUIFragmentAttribute {
8198                                name: "disabled".into(),
8199                                attr_start: true,
8200                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8201                                ..Default::default()
8202                            },
8203                        )),
8204                    },
8205                    WebUIFragment::raw(">"),
8206                    WebUIFragment::component("my-component"),
8207                    WebUIFragment::raw("</my-component>"),
8208                ],
8209
8210                contains_boundary: false,
8211            },
8212        );
8213        fragments.insert(
8214            "my-component".to_string(),
8215            FragmentList {
8216                fragments: vec![
8217                    WebUIFragment::if_cond(
8218                        ConditionExpr::identifier("disabled"),
8219                        "disabledTemplate",
8220                    ),
8221                    WebUIFragment::if_cond(
8222                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8223                        "enabledTemplate",
8224                    ),
8225                ],
8226                contains_boundary: false,
8227            },
8228        );
8229        fragments.insert(
8230            "disabledTemplate".to_string(),
8231            FragmentList {
8232                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
8233                contains_boundary: false,
8234            },
8235        );
8236        fragments.insert(
8237            "enabledTemplate".to_string(),
8238            FragmentList {
8239                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
8240                contains_boundary: false,
8241            },
8242        );
8243        let protocol = WebUIProtocol::new(fragments);
8244        let state = test_json!({"isDisabled": true});
8245        let mut writer = TestWriter::new();
8246        handle(
8247            &protocol,
8248            &state,
8249            &RenderOptions::new("index.html", "/"),
8250            &mut writer,
8251        )
8252        .unwrap();
8253        assert_eq!(
8254            writer.get_content(),
8255            "<my-component disabled><span>Disabled</span></my-component>"
8256        );
8257    }
8258
8259    #[test]
8260    fn test_bool_component_state_false() {
8261        let mut fragments = HashMap::new();
8262        fragments.insert(
8263            "index.html".to_string(),
8264            FragmentList {
8265                fragments: vec![
8266                    WebUIFragment::raw("<my-component"),
8267                    WebUIFragment {
8268                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8269                            WebUIFragmentAttribute {
8270                                name: "disabled".into(),
8271                                attr_start: true,
8272                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8273                                ..Default::default()
8274                            },
8275                        )),
8276                    },
8277                    WebUIFragment::raw(">"),
8278                    WebUIFragment::component("my-component"),
8279                    WebUIFragment::raw("</my-component>"),
8280                ],
8281
8282                contains_boundary: false,
8283            },
8284        );
8285        fragments.insert(
8286            "my-component".to_string(),
8287            FragmentList {
8288                fragments: vec![
8289                    WebUIFragment::if_cond(
8290                        ConditionExpr::identifier("disabled"),
8291                        "disabledTemplate",
8292                    ),
8293                    WebUIFragment::if_cond(
8294                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8295                        "enabledTemplate",
8296                    ),
8297                ],
8298                contains_boundary: false,
8299            },
8300        );
8301        fragments.insert(
8302            "disabledTemplate".to_string(),
8303            FragmentList {
8304                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
8305                contains_boundary: false,
8306            },
8307        );
8308        fragments.insert(
8309            "enabledTemplate".to_string(),
8310            FragmentList {
8311                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
8312                contains_boundary: false,
8313            },
8314        );
8315        let protocol = WebUIProtocol::new(fragments);
8316        let state = test_json!({"isDisabled": false});
8317        let mut writer = TestWriter::new();
8318        handle(
8319            &protocol,
8320            &state,
8321            &RenderOptions::new("index.html", "/"),
8322            &mut writer,
8323        )
8324        .unwrap();
8325        assert_eq!(
8326            writer.get_content(),
8327            "<my-component><span>Enabled</span></my-component>"
8328        );
8329    }
8330
8331    #[test]
8332    fn test_bool_component_state_forward() {
8333        let mut fragments = HashMap::new();
8334        fragments.insert(
8335            "index.html".to_string(),
8336            FragmentList {
8337                fragments: vec![
8338                    WebUIFragment::raw("<parent-component"),
8339                    WebUIFragment {
8340                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8341                            WebUIFragmentAttribute {
8342                                name: "disabled".into(),
8343                                attr_start: true,
8344                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8345                                ..Default::default()
8346                            },
8347                        )),
8348                    },
8349                    WebUIFragment::raw(">"),
8350                    WebUIFragment::component("parent-component"),
8351                    WebUIFragment::raw("</parent-component>"),
8352                ],
8353
8354                contains_boundary: false,
8355            },
8356        );
8357        fragments.insert(
8358            "parent-component".to_string(),
8359            FragmentList {
8360                fragments: vec![
8361                    WebUIFragment::if_cond(
8362                        ConditionExpr::identifier("disabled"),
8363                        "parentDisabledTemplate",
8364                    ),
8365                    WebUIFragment::raw("<child-component"),
8366                    WebUIFragment {
8367                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8368                            WebUIFragmentAttribute {
8369                                name: "disabled".into(),
8370                                attr_start: true,
8371                                condition_tree: Some(ConditionExpr::identifier("disabled")),
8372                                ..Default::default()
8373                            },
8374                        )),
8375                    },
8376                    WebUIFragment::raw(">"),
8377                    WebUIFragment::component("child-component"),
8378                    WebUIFragment::raw("</child-component>"),
8379                ],
8380
8381                contains_boundary: false,
8382            },
8383        );
8384        fragments.insert(
8385            "parentDisabledTemplate".to_string(),
8386            FragmentList {
8387                fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
8388                contains_boundary: false,
8389            },
8390        );
8391        fragments.insert(
8392            "child-component".to_string(),
8393            FragmentList {
8394                fragments: vec![
8395                    WebUIFragment::if_cond(
8396                        ConditionExpr::identifier("disabled"),
8397                        "childDisabledTemplate",
8398                    ),
8399                    WebUIFragment::if_cond(
8400                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8401                        "childEnabledTemplate",
8402                    ),
8403                ],
8404                contains_boundary: false,
8405            },
8406        );
8407        fragments.insert(
8408            "childDisabledTemplate".to_string(),
8409            FragmentList {
8410                fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
8411                contains_boundary: false,
8412            },
8413        );
8414        fragments.insert(
8415            "childEnabledTemplate".to_string(),
8416            FragmentList {
8417                fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
8418                contains_boundary: false,
8419            },
8420        );
8421
8422        // Test case 1: isDisabled = true
8423        {
8424            let protocol = WebUIProtocol::new(fragments.clone());
8425            let state = test_json!({"isDisabled": true});
8426            let mut writer = TestWriter::new();
8427            handle(
8428                &protocol,
8429                &state,
8430                &RenderOptions::new("index.html", "/"),
8431                &mut writer,
8432            )
8433            .unwrap();
8434            assert_eq!(
8435                writer.get_content(),
8436                "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
8437            );
8438        }
8439
8440        // Test case 2: isDisabled = false
8441        {
8442            let protocol = WebUIProtocol::new(fragments.clone());
8443            let state = test_json!({"isDisabled": false});
8444            let mut writer = TestWriter::new();
8445            handle(
8446                &protocol,
8447                &state,
8448                &RenderOptions::new("index.html", "/"),
8449                &mut writer,
8450            )
8451            .unwrap();
8452            assert_eq!(
8453                writer.get_content(),
8454                "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
8455            );
8456        }
8457    }
8458
8459    // ── GROUP 9: Hydration (SKIP) ─────────────────────────────────────
8460
8461    // TODO: test_hydration - requires FAST handler plugin integration; see plugin/fast.rs
8462
8463    // ── Component tests ──────────────────────────────────────────────
8464
8465    #[test]
8466    fn test_component_with_template() {
8467        let mut fragments = HashMap::new();
8468        fragments.insert(
8469            "index.html".to_string(),
8470            FragmentList {
8471                fragments: vec![
8472                    WebUIFragment::raw("<custom-element>"),
8473                    WebUIFragment::component("custom-element"),
8474                    WebUIFragment::raw("</custom-element>"),
8475                ],
8476                contains_boundary: false,
8477            },
8478        );
8479        fragments.insert(
8480            "custom-element".to_string(),
8481            FragmentList {
8482                fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
8483                contains_boundary: false,
8484            },
8485        );
8486        let protocol = WebUIProtocol::new(fragments);
8487        let state = test_json!({});
8488        let mut writer = TestWriter::new();
8489        handle(
8490            &protocol,
8491            &state,
8492            &RenderOptions::new("index.html", "/"),
8493            &mut writer,
8494        )
8495        .unwrap();
8496        assert_eq!(
8497            writer.get_content(),
8498            "<custom-element><div>Custom Element</div></custom-element>"
8499        );
8500        assert!(writer.is_ended());
8501    }
8502
8503    #[test]
8504    fn test_component_with_slots() {
8505        let mut fragments = HashMap::new();
8506        fragments.insert(
8507            "index.html".to_string(),
8508            FragmentList {
8509                fragments: vec![
8510                    WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
8511                    WebUIFragment::component("custom-element"),
8512                    WebUIFragment::raw("Hello World</custom-element>"),
8513                ],
8514                contains_boundary: false,
8515            },
8516        );
8517        fragments.insert(
8518            "custom-element".to_string(),
8519            FragmentList {
8520                fragments: vec![WebUIFragment::raw("<slot></slot>")],
8521                contains_boundary: false,
8522            },
8523        );
8524        let protocol = WebUIProtocol::new(fragments);
8525        let state = test_json!({});
8526        let mut writer = TestWriter::new();
8527        handle(
8528            &protocol,
8529            &state,
8530            &RenderOptions::new("index.html", "/"),
8531            &mut writer,
8532        )
8533        .unwrap();
8534        assert_eq!(
8535            writer.get_content(),
8536            "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
8537        );
8538        assert!(writer.is_ended());
8539    }
8540
8541    #[test]
8542    fn test_multiple_nested_components() {
8543        let mut fragments = HashMap::new();
8544        fragments.insert(
8545            "index.html".to_string(),
8546            FragmentList {
8547                fragments: vec![
8548                    WebUIFragment::raw("<div>"),
8549                    WebUIFragment::for_loop("item", "items", "templateRepeat"),
8550                    WebUIFragment::raw("</div>"),
8551                ],
8552                contains_boundary: false,
8553            },
8554        );
8555        fragments.insert(
8556            "custom-button".to_string(),
8557            FragmentList {
8558                fragments: vec![WebUIFragment::raw("<slot></slot>")],
8559                contains_boundary: false,
8560            },
8561        );
8562        fragments.insert(
8563            "custom-element".to_string(),
8564            FragmentList {
8565                fragments: vec![
8566                    WebUIFragment::raw("<custom-child>"),
8567                    WebUIFragment::component("custom-child"),
8568                    WebUIFragment::raw("</custom-child><slot></slot>"),
8569                ],
8570                contains_boundary: false,
8571            },
8572        );
8573        fragments.insert(
8574            "custom-child".to_string(),
8575            FragmentList {
8576                fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
8577                contains_boundary: false,
8578            },
8579        );
8580        fragments.insert(
8581            "templateRepeat".to_string(),
8582            FragmentList {
8583                fragments: vec![
8584                    WebUIFragment::raw("<custom-element>"),
8585                    WebUIFragment::component("custom-element"),
8586                    WebUIFragment::raw("<custom-button>"),
8587                    WebUIFragment::component("custom-button"),
8588                    WebUIFragment::raw("Ok</custom-button></custom-element>"),
8589                ],
8590                contains_boundary: false,
8591            },
8592        );
8593        let protocol = WebUIProtocol::new(fragments);
8594        let state = test_json!({"items": [{"name": "Item1"}]});
8595        let mut writer = TestWriter::new();
8596        handle(
8597            &protocol,
8598            &state,
8599            &RenderOptions::new("index.html", "/"),
8600            &mut writer,
8601        )
8602        .unwrap();
8603        assert_eq!(
8604            writer.get_content(),
8605            "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
8606        );
8607        assert!(writer.is_ended());
8608    }
8609
8610    // ── Conditional tests ────────────────────────────────────────────
8611
8612    #[test]
8613    fn test_if_with_binary_expression() {
8614        let mut fragments = HashMap::new();
8615        fragments.insert(
8616            "index.html".to_string(),
8617            FragmentList {
8618                fragments: vec![
8619                    WebUIFragment::raw("<div>"),
8620                    WebUIFragment::if_cond(
8621                        ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
8622                        "if-1",
8623                    ),
8624                    WebUIFragment::raw("</div>"),
8625                ],
8626                contains_boundary: false,
8627            },
8628        );
8629        fragments.insert(
8630            "if-1".to_string(),
8631            FragmentList {
8632                fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
8633                contains_boundary: false,
8634            },
8635        );
8636        let protocol = WebUIProtocol::new(fragments);
8637
8638        // True case: x = 10 > 5
8639        let state_true = test_json!({"x": 10});
8640        let mut writer_true = TestWriter::new();
8641        handle(
8642            &protocol,
8643            &state_true,
8644            &RenderOptions::new("index.html", "/"),
8645            &mut writer_true,
8646        )
8647        .unwrap();
8648        assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
8649
8650        // False case: x = 1 <= 5
8651        let state_false = test_json!({"x": 1});
8652        let mut writer_false = TestWriter::new();
8653        handle(
8654            &protocol,
8655            &state_false,
8656            &RenderOptions::new("index.html", "/"),
8657            &mut writer_false,
8658        )
8659        .unwrap();
8660        assert_eq!(writer_false.get_content(), "<div></div>");
8661    }
8662
8663    #[test]
8664    fn test_for_if_overlapping_local_state() {
8665        let mut fragments = HashMap::new();
8666        fragments.insert(
8667            "index.html".to_string(),
8668            FragmentList {
8669                fragments: vec![
8670                    WebUIFragment::raw("<div>"),
8671                    WebUIFragment::for_loop("item", "items", "template1"),
8672                    WebUIFragment::raw("</div>"),
8673                ],
8674                contains_boundary: false,
8675            },
8676        );
8677        fragments.insert(
8678            "template1".to_string(),
8679            FragmentList {
8680                fragments: vec![
8681                    WebUIFragment::raw("<div>"),
8682                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8683                    WebUIFragment::raw("</div>"),
8684                ],
8685                contains_boundary: false,
8686            },
8687        );
8688        fragments.insert(
8689            "ifBlock".to_string(),
8690            FragmentList {
8691                fragments: vec![
8692                    WebUIFragment::raw("<span>"),
8693                    WebUIFragment::signal("item.label", false),
8694                    WebUIFragment::raw("</span>"),
8695                ],
8696                contains_boundary: false,
8697            },
8698        );
8699        let protocol = WebUIProtocol::new(fragments);
8700        let state = test_json!({
8701            "flag": false,
8702            "items": [
8703                {"label": "A", "flag": true},
8704                {"label": "B", "flag": false},
8705                {"label": "C", "flag": true}
8706            ]
8707        });
8708        let mut writer = TestWriter::new();
8709        handle(
8710            &protocol,
8711            &state,
8712            &RenderOptions::new("index.html", "/"),
8713            &mut writer,
8714        )
8715        .unwrap();
8716        assert_eq!(
8717            writer.get_content(),
8718            "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
8719        );
8720    }
8721
8722    #[test]
8723    fn test_for_if_global_flag_no_effect() {
8724        let mut fragments = HashMap::new();
8725        fragments.insert(
8726            "index.html".to_string(),
8727            FragmentList {
8728                fragments: vec![
8729                    WebUIFragment::raw("<div>"),
8730                    WebUIFragment::for_loop("item", "items", "template1"),
8731                    WebUIFragment::raw("</div>"),
8732                ],
8733                contains_boundary: false,
8734            },
8735        );
8736        fragments.insert(
8737            "template1".to_string(),
8738            FragmentList {
8739                fragments: vec![
8740                    WebUIFragment::raw("<div>"),
8741                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8742                    WebUIFragment::raw("</div>"),
8743                ],
8744                contains_boundary: false,
8745            },
8746        );
8747        fragments.insert(
8748            "ifBlock".to_string(),
8749            FragmentList {
8750                fragments: vec![
8751                    WebUIFragment::raw("<span>"),
8752                    WebUIFragment::signal("item.label", false),
8753                    WebUIFragment::raw("</span>"),
8754                ],
8755                contains_boundary: false,
8756            },
8757        );
8758        let protocol = WebUIProtocol::new(fragments);
8759        let state = test_json!({
8760            "item": {"flag": true},
8761            "items": [
8762                {"label": "A", "flag": false},
8763                {"label": "B", "flag": true},
8764                {"label": "C", "flag": false}
8765            ]
8766        });
8767        let mut writer = TestWriter::new();
8768        handle(
8769            &protocol,
8770            &state,
8771            &RenderOptions::new("index.html", "/"),
8772            &mut writer,
8773        )
8774        .unwrap();
8775        assert_eq!(
8776            writer.get_content(),
8777            "<div><div></div><div><span>B</span></div><div></div></div>"
8778        );
8779    }
8780
8781    // ── Recursive template test ──────────────────────────────────────
8782
8783    #[test]
8784    fn test_recursive_template_refs() {
8785        let mut fragments = HashMap::new();
8786        fragments.insert(
8787            "index.html".to_string(),
8788            FragmentList {
8789                fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
8790                contains_boundary: false,
8791            },
8792        );
8793        fragments.insert(
8794            "static".to_string(),
8795            FragmentList {
8796                fragments: vec![
8797                    WebUIFragment::raw("<div expanded=\""),
8798                    WebUIFragment::signal("item.expanded", false),
8799                    WebUIFragment::raw("\" class=\""),
8800                    WebUIFragment::signal("testScenario", false),
8801                    WebUIFragment::raw("\"><span>"),
8802                    WebUIFragment::signal("item.name", false),
8803                    WebUIFragment::raw("</span>"),
8804                    WebUIFragment::for_loop("item", "item.children", "static"),
8805                    WebUIFragment::raw("</div>"),
8806                ],
8807                contains_boundary: false,
8808            },
8809        );
8810        let protocol = WebUIProtocol::new(fragments);
8811        let state = test_json!({
8812            "testScenario": "RecursiveTemplatesWithGlobalState",
8813            "items": [
8814                {"name": "A", "expanded": "false", "children": []},
8815                {"name": "B", "expanded": "true", "children": [
8816                    {"name": "C", "expanded": "false"},
8817                    {"name": "D", "expanded": "false"}
8818                ]},
8819                {"name": "E", "expanded": "false"}
8820            ]
8821        });
8822        let mut writer = TestWriter::new();
8823        handle(
8824            &protocol,
8825            &state,
8826            &RenderOptions::new("index.html", "/"),
8827            &mut writer,
8828        )
8829        .unwrap();
8830        assert_eq!(
8831            writer.get_content(),
8832            "<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>"
8833        );
8834    }
8835
8836    // ── Advanced state management tests ──────────────────────────────
8837
8838    #[test]
8839    fn test_component_in_for_no_local_access() {
8840        let mut fragments = HashMap::new();
8841        fragments.insert(
8842            "index.html".to_string(),
8843            FragmentList {
8844                fragments: vec![
8845                    WebUIFragment::raw("<div>"),
8846                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8847                    WebUIFragment::raw("</div>"),
8848                ],
8849                contains_boundary: false,
8850            },
8851        );
8852        fragments.insert(
8853            "templateComponent".to_string(),
8854            FragmentList {
8855                fragments: vec![
8856                    WebUIFragment::raw("<component-tag>"),
8857                    WebUIFragment::component("my-component"),
8858                    WebUIFragment::raw("</component-tag>"),
8859                ],
8860                contains_boundary: false,
8861            },
8862        );
8863        fragments.insert(
8864            "my-component".to_string(),
8865            FragmentList {
8866                fragments: vec![
8867                    WebUIFragment::raw("<span>"),
8868                    WebUIFragment::signal("name", false),
8869                    WebUIFragment::raw("</span>"),
8870                ],
8871                contains_boundary: false,
8872            },
8873        );
8874        let protocol = WebUIProtocol::new(fragments);
8875        let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
8876        let mut writer = TestWriter::new();
8877        handle(
8878            &protocol,
8879            &state,
8880            &RenderOptions::new("index.html", "/"),
8881            &mut writer,
8882        )
8883        .unwrap();
8884        assert_eq!(
8885            writer.get_content(),
8886            "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
8887        );
8888    }
8889
8890    #[test]
8891    fn test_nested_for_hierarchical_state() {
8892        let mut fragments = HashMap::new();
8893        fragments.insert(
8894            "index.html".to_string(),
8895            FragmentList {
8896                fragments: vec![
8897                    WebUIFragment::raw("<div>"),
8898                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8899                    WebUIFragment::raw("</div>"),
8900                ],
8901                contains_boundary: false,
8902            },
8903        );
8904        fragments.insert(
8905            "outerTemplate".to_string(),
8906            FragmentList {
8907                fragments: vec![
8908                    WebUIFragment::raw("<section>"),
8909                    WebUIFragment::signal("globalPrefix", false),
8910                    WebUIFragment::signal("outerItem.outerLabel", false),
8911                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8912                    WebUIFragment::raw("</section>"),
8913                ],
8914                contains_boundary: false,
8915            },
8916        );
8917        fragments.insert(
8918            "innerTemplate".to_string(),
8919            FragmentList {
8920                fragments: vec![
8921                    WebUIFragment::raw("<p>"),
8922                    WebUIFragment::signal("globalPrefix", false),
8923                    WebUIFragment::signal("outerItem.outerLabel", false),
8924                    WebUIFragment::raw(": "),
8925                    WebUIFragment::signal("innerItem.innerLabel", false),
8926                    WebUIFragment::raw("</p>"),
8927                ],
8928                contains_boundary: false,
8929            },
8930        );
8931        let protocol = WebUIProtocol::new(fragments);
8932        let state = test_json!({
8933            "globalPrefix": "Prefix: ",
8934            "outerItems": [
8935                {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8936                {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
8937            ]
8938        });
8939        let mut writer = TestWriter::new();
8940        handle(
8941            &protocol,
8942            &state,
8943            &RenderOptions::new("index.html", "/"),
8944            &mut writer,
8945        )
8946        .unwrap();
8947        assert_eq!(
8948            writer.get_content(),
8949            "<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>"
8950        );
8951    }
8952
8953    #[test]
8954    fn test_component_in_for_global_only() {
8955        let mut fragments = HashMap::new();
8956        fragments.insert(
8957            "index.html".to_string(),
8958            FragmentList {
8959                fragments: vec![
8960                    WebUIFragment::raw("<div>"),
8961                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8962                    WebUIFragment::raw("</div>"),
8963                ],
8964                contains_boundary: false,
8965            },
8966        );
8967        fragments.insert(
8968            "templateComponent".to_string(),
8969            FragmentList {
8970                fragments: vec![
8971                    WebUIFragment::raw("<component-tag>"),
8972                    WebUIFragment::component("my-component"),
8973                    WebUIFragment::raw("</component-tag>"),
8974                ],
8975                contains_boundary: false,
8976            },
8977        );
8978        fragments.insert(
8979            "my-component".to_string(),
8980            FragmentList {
8981                fragments: vec![
8982                    WebUIFragment::raw("<span>"),
8983                    WebUIFragment::signal("name", false),
8984                    WebUIFragment::raw("-"),
8985                    WebUIFragment::signal("globalSuffix", false),
8986                    WebUIFragment::raw("</span>"),
8987                ],
8988                contains_boundary: false,
8989            },
8990        );
8991        let protocol = WebUIProtocol::new(fragments);
8992        let state =
8993            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8994        let mut writer = TestWriter::new();
8995        handle(
8996            &protocol,
8997            &state,
8998            &RenderOptions::new("index.html", "/"),
8999            &mut writer,
9000        )
9001        .unwrap();
9002        assert_eq!(
9003            writer.get_content(),
9004            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
9005        );
9006    }
9007
9008    #[test]
9009    fn test_component_no_item_moniker() {
9010        let mut fragments = HashMap::new();
9011        fragments.insert(
9012            "index.html".to_string(),
9013            FragmentList {
9014                fragments: vec![
9015                    WebUIFragment::raw("<div>"),
9016                    WebUIFragment::for_loop("item", "items", "templateComponent"),
9017                    WebUIFragment::raw("</div>"),
9018                ],
9019                contains_boundary: false,
9020            },
9021        );
9022        fragments.insert(
9023            "templateComponent".to_string(),
9024            FragmentList {
9025                fragments: vec![
9026                    WebUIFragment::raw("<component-tag>"),
9027                    WebUIFragment::component("my-component"),
9028                    WebUIFragment::raw("</component-tag>"),
9029                ],
9030                contains_boundary: false,
9031            },
9032        );
9033        fragments.insert(
9034            "my-component".to_string(),
9035            FragmentList {
9036                fragments: vec![
9037                    WebUIFragment::raw("<span>"),
9038                    WebUIFragment::signal("item.name", false),
9039                    WebUIFragment::raw("-"),
9040                    WebUIFragment::signal("globalSuffix", false),
9041                    WebUIFragment::raw("</span>"),
9042                ],
9043                contains_boundary: false,
9044            },
9045        );
9046        let protocol = WebUIProtocol::new(fragments);
9047        let state =
9048            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
9049        let mut writer = TestWriter::new();
9050        handle(
9051            &protocol,
9052            &state,
9053            &RenderOptions::new("index.html", "/"),
9054            &mut writer,
9055        )
9056        .unwrap();
9057        assert_eq!(
9058            writer.get_content(),
9059            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
9060        );
9061    }
9062
9063    #[test]
9064    fn test_for_nonqualified_uses_global() {
9065        let mut fragments = HashMap::new();
9066        fragments.insert(
9067            "index.html".to_string(),
9068            FragmentList {
9069                fragments: vec![
9070                    WebUIFragment::raw("<div>"),
9071                    WebUIFragment::for_loop("item", "items", "template1"),
9072                    WebUIFragment::raw("</div>"),
9073                ],
9074                contains_boundary: false,
9075            },
9076        );
9077        fragments.insert(
9078            "template1".to_string(),
9079            FragmentList {
9080                fragments: vec![
9081                    WebUIFragment::raw("<span>"),
9082                    WebUIFragment::signal("name", false),
9083                    WebUIFragment::raw("</span>"),
9084                ],
9085                contains_boundary: false,
9086            },
9087        );
9088        let protocol = WebUIProtocol::new(fragments);
9089        let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
9090        let mut writer = TestWriter::new();
9091        handle(
9092            &protocol,
9093            &state,
9094            &RenderOptions::new("index.html", "/"),
9095            &mut writer,
9096        )
9097        .unwrap();
9098        assert_eq!(
9099            writer.get_content(),
9100            "<div><span>GlobalName</span><span>GlobalName</span></div>"
9101        );
9102    }
9103
9104    #[test]
9105    fn test_nested_for_if_interleaved() {
9106        let mut fragments = HashMap::new();
9107        fragments.insert(
9108            "index.html".to_string(),
9109            FragmentList {
9110                fragments: vec![
9111                    WebUIFragment::raw("<div>"),
9112                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9113                    WebUIFragment::raw("</div>"),
9114                ],
9115                contains_boundary: false,
9116            },
9117        );
9118        fragments.insert(
9119            "outerTemplate".to_string(),
9120            FragmentList {
9121                fragments: vec![
9122                    WebUIFragment::raw("<section>"),
9123                    WebUIFragment::signal("globalPrefix", false),
9124                    WebUIFragment::signal("outerItem.outerLabel", false),
9125                    WebUIFragment::if_cond(
9126                        ConditionExpr::identifier("outerItem.include"),
9127                        "ifTemplate",
9128                    ),
9129                    WebUIFragment::raw("</section>"),
9130                ],
9131                contains_boundary: false,
9132            },
9133        );
9134        fragments.insert(
9135            "ifTemplate".to_string(),
9136            FragmentList {
9137                fragments: vec![
9138                    WebUIFragment::raw("<div>"),
9139                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
9140                    WebUIFragment::raw("</div>"),
9141                ],
9142                contains_boundary: false,
9143            },
9144        );
9145        fragments.insert(
9146            "innerTemplate".to_string(),
9147            FragmentList {
9148                fragments: vec![
9149                    WebUIFragment::raw("<p>"),
9150                    WebUIFragment::signal("globalSuffix", false),
9151                    WebUIFragment::raw(": "),
9152                    WebUIFragment::signal("innerItem.innerLabel", false),
9153                    WebUIFragment::raw("</p>"),
9154                ],
9155                contains_boundary: false,
9156            },
9157        );
9158        let protocol = WebUIProtocol::new(fragments);
9159        let state = test_json!({
9160            "globalPrefix": "Prefix: ",
9161            "globalSuffix": "Suffix",
9162            "outerItems": [
9163                {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
9164                {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
9165                {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
9166            ]
9167        });
9168        let mut writer = TestWriter::new();
9169        handle(
9170            &protocol,
9171            &state,
9172            &RenderOptions::new("index.html", "/"),
9173            &mut writer,
9174        )
9175        .unwrap();
9176        assert_eq!(
9177            writer.get_content(),
9178            "<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>"
9179        );
9180    }
9181
9182    #[test]
9183    fn test_nested_for_if_outer_state() {
9184        let mut fragments = HashMap::new();
9185        fragments.insert(
9186            "index.html".to_string(),
9187            FragmentList {
9188                fragments: vec![
9189                    WebUIFragment::raw("<div>"),
9190                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9191                    WebUIFragment::raw("</div>"),
9192                ],
9193                contains_boundary: false,
9194            },
9195        );
9196        fragments.insert(
9197            "outerTemplate".to_string(),
9198            FragmentList {
9199                fragments: vec![
9200                    WebUIFragment::raw("<section>"),
9201                    WebUIFragment::signal("globalPrefix", false),
9202                    WebUIFragment::signal("outerItem.label", false),
9203                    WebUIFragment::for_loop(
9204                        "middleItem",
9205                        "outerItem.middleItems",
9206                        "middleTemplate",
9207                    ),
9208                    WebUIFragment::raw("</section>"),
9209                ],
9210                contains_boundary: false,
9211            },
9212        );
9213        fragments.insert(
9214            "middleTemplate".to_string(),
9215            FragmentList {
9216                fragments: vec![
9217                    WebUIFragment::raw("<div>"),
9218                    WebUIFragment::if_cond(
9219                        ConditionExpr::identifier("outerItem.active"),
9220                        "ifTemplate",
9221                    ),
9222                    WebUIFragment::raw("</div>"),
9223                ],
9224                contains_boundary: false,
9225            },
9226        );
9227        fragments.insert(
9228            "ifTemplate".to_string(),
9229            FragmentList {
9230                fragments: vec![
9231                    WebUIFragment::raw("<p>"),
9232                    WebUIFragment::signal("middleItem.value", false),
9233                    WebUIFragment::raw("</p>"),
9234                ],
9235                contains_boundary: false,
9236            },
9237        );
9238        let protocol = WebUIProtocol::new(fragments);
9239        let state = test_json!({
9240            "globalPrefix": "GP-",
9241            "outerItems": [
9242                {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
9243                {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
9244                {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
9245            ]
9246        });
9247        let mut writer = TestWriter::new();
9248        handle(
9249            &protocol,
9250            &state,
9251            &RenderOptions::new("index.html", "/"),
9252            &mut writer,
9253        )
9254        .unwrap();
9255        assert_eq!(
9256            writer.get_content(),
9257            "<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>"
9258        );
9259    }
9260
9261    #[test]
9262    fn test_nested_for_if_inner_state() {
9263        let mut fragments = HashMap::new();
9264        fragments.insert(
9265            "index.html".to_string(),
9266            FragmentList {
9267                fragments: vec![
9268                    WebUIFragment::raw("<div>"),
9269                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9270                    WebUIFragment::raw("</div>"),
9271                ],
9272                contains_boundary: false,
9273            },
9274        );
9275        fragments.insert(
9276            "outerTemplate".to_string(),
9277            FragmentList {
9278                fragments: vec![
9279                    WebUIFragment::raw("<section>"),
9280                    WebUIFragment::signal("outerItem.label", false),
9281                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
9282                    WebUIFragment::raw("</section>"),
9283                ],
9284                contains_boundary: false,
9285            },
9286        );
9287        fragments.insert(
9288            "innerTemplate".to_string(),
9289            FragmentList {
9290                fragments: vec![
9291                    WebUIFragment::raw("<article>"),
9292                    WebUIFragment::if_cond(
9293                        ConditionExpr::identifier("innerItem.show"),
9294                        "ifTemplate",
9295                    ),
9296                    WebUIFragment::raw("</article>"),
9297                ],
9298                contains_boundary: false,
9299            },
9300        );
9301        fragments.insert(
9302            "ifTemplate".to_string(),
9303            FragmentList {
9304                fragments: vec![
9305                    WebUIFragment::raw("<p>"),
9306                    WebUIFragment::signal("innerItem.detail", false),
9307                    WebUIFragment::raw("</p>"),
9308                ],
9309                contains_boundary: false,
9310            },
9311        );
9312        let protocol = WebUIProtocol::new(fragments);
9313        let state = test_json!({
9314            "outerItems": [
9315                {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
9316                {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
9317            ]
9318        });
9319        let mut writer = TestWriter::new();
9320        handle(
9321            &protocol,
9322            &state,
9323            &RenderOptions::new("index.html", "/"),
9324            &mut writer,
9325        )
9326        .unwrap();
9327        assert_eq!(
9328            writer.get_content(),
9329            "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
9330        );
9331    }
9332
9333    #[test]
9334    fn test_for_merge_local_global_monikers() {
9335        let mut fragments = HashMap::new();
9336        fragments.insert(
9337            "index.html".to_string(),
9338            FragmentList {
9339                fragments: vec![
9340                    WebUIFragment::raw("<div>"),
9341                    WebUIFragment::for_loop("item", "items", "template1"),
9342                    WebUIFragment::raw("</div>"),
9343                ],
9344                contains_boundary: false,
9345            },
9346        );
9347        fragments.insert(
9348            "template1".to_string(),
9349            FragmentList {
9350                fragments: vec![
9351                    WebUIFragment::raw("<span>"),
9352                    WebUIFragment::signal("item.name", false),
9353                    WebUIFragment::raw("-"),
9354                    WebUIFragment::signal("item.globalValue", false),
9355                    WebUIFragment::raw("-"),
9356                    WebUIFragment::signal("item.localOnly", false),
9357                    WebUIFragment::raw("-"),
9358                    WebUIFragment::signal("item.otherVal", false),
9359                    WebUIFragment::raw("</span>"),
9360                ],
9361                contains_boundary: false,
9362            },
9363        );
9364        let protocol = WebUIProtocol::new(fragments);
9365        let state = test_json!({
9366            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
9367            "items": [
9368                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
9369                {"name": "Local2", "localOnly": "Only2"}
9370            ]
9371        });
9372        let mut writer = TestWriter::new();
9373        handle(
9374            &protocol,
9375            &state,
9376            &RenderOptions::new("index.html", "/"),
9377            &mut writer,
9378        )
9379        .unwrap();
9380        assert_eq!(
9381            writer.get_content(),
9382            "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
9383        );
9384    }
9385
9386    #[test]
9387    fn test_component_in_for_global_moniker_shadow() {
9388        let mut fragments = HashMap::new();
9389        fragments.insert(
9390            "index.html".to_string(),
9391            FragmentList {
9392                fragments: vec![
9393                    WebUIFragment::raw("<div>"),
9394                    WebUIFragment::for_loop("item", "items", "templateComponent"),
9395                    WebUIFragment::raw("</div>"),
9396                ],
9397                contains_boundary: false,
9398            },
9399        );
9400        fragments.insert(
9401            "templateComponent".to_string(),
9402            FragmentList {
9403                fragments: vec![
9404                    WebUIFragment::raw("<component-tag>"),
9405                    WebUIFragment::component("my-component"),
9406                    WebUIFragment::raw("</component-tag>"),
9407                ],
9408                contains_boundary: false,
9409            },
9410        );
9411        fragments.insert(
9412            "my-component".to_string(),
9413            FragmentList {
9414                fragments: vec![
9415                    WebUIFragment::raw("<span>"),
9416                    WebUIFragment::signal("name", false),
9417                    WebUIFragment::raw("-"),
9418                    WebUIFragment::signal("item.globalValue", false),
9419                    WebUIFragment::raw("-"),
9420                    WebUIFragment::signal("localOnly", false),
9421                    WebUIFragment::raw("-"),
9422                    WebUIFragment::signal("item.otherVal", false),
9423                    WebUIFragment::raw("</span>"),
9424                ],
9425                contains_boundary: false,
9426            },
9427        );
9428        let protocol = WebUIProtocol::new(fragments);
9429        let state = test_json!({
9430            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
9431            "items": [
9432                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
9433                {"name": "Local2", "localOnly": "Only2"}
9434            ]
9435        });
9436        let mut writer = TestWriter::new();
9437        handle(
9438            &protocol,
9439            &state,
9440            &RenderOptions::new("index.html", "/"),
9441            &mut writer,
9442        )
9443        .unwrap();
9444        assert_eq!(
9445            writer.get_content(),
9446            "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
9447        );
9448    }
9449
9450    #[test]
9451    fn test_if_in_nested_for_local_flag() {
9452        let mut fragments = HashMap::new();
9453        fragments.insert(
9454            "index.html".to_string(),
9455            FragmentList {
9456                fragments: vec![
9457                    WebUIFragment::raw("<div>"),
9458                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
9459                    WebUIFragment::raw("</div>"),
9460                ],
9461                contains_boundary: false,
9462            },
9463        );
9464        fragments.insert(
9465            "outerTemplate".to_string(),
9466            FragmentList {
9467                fragments: vec![
9468                    WebUIFragment::raw("<section>"),
9469                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
9470                    WebUIFragment::raw("</section>"),
9471                ],
9472                contains_boundary: false,
9473            },
9474        );
9475        fragments.insert(
9476            "innerTemplate".to_string(),
9477            FragmentList {
9478                fragments: vec![WebUIFragment::if_cond(
9479                    ConditionExpr::identifier("inner_item.flag"),
9480                    "ifInner",
9481                )],
9482                contains_boundary: false,
9483            },
9484        );
9485        fragments.insert(
9486            "ifInner".to_string(),
9487            FragmentList {
9488                fragments: vec![
9489                    WebUIFragment::raw("<p>"),
9490                    WebUIFragment::signal("inner_item.value", false),
9491                    WebUIFragment::raw("</p>"),
9492                ],
9493                contains_boundary: false,
9494            },
9495        );
9496        let protocol = WebUIProtocol::new(fragments);
9497        let state = test_json!({
9498            "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
9499            "inner_item": {"flag": false}
9500        });
9501        let mut writer = TestWriter::new();
9502        handle(
9503            &protocol,
9504            &state,
9505            &RenderOptions::new("index.html", "/"),
9506            &mut writer,
9507        )
9508        .unwrap();
9509        assert_eq!(
9510            writer.get_content(),
9511            "<div><section><p>X</p></section></div>"
9512        );
9513    }
9514
9515    #[test]
9516    fn test_if_in_nested_for_global_fallback() {
9517        let mut fragments = HashMap::new();
9518        fragments.insert(
9519            "index.html".to_string(),
9520            FragmentList {
9521                fragments: vec![
9522                    WebUIFragment::raw("<div>"),
9523                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
9524                    WebUIFragment::raw("</div>"),
9525                ],
9526                contains_boundary: false,
9527            },
9528        );
9529        fragments.insert(
9530            "outerTemplate".to_string(),
9531            FragmentList {
9532                fragments: vec![
9533                    WebUIFragment::raw("<section>"),
9534                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
9535                    WebUIFragment::raw("</section>"),
9536                ],
9537                contains_boundary: false,
9538            },
9539        );
9540        fragments.insert(
9541            "innerTemplate".to_string(),
9542            FragmentList {
9543                fragments: vec![WebUIFragment::if_cond(
9544                    ConditionExpr::identifier("inner_item.flag"),
9545                    "ifInner",
9546                )],
9547                contains_boundary: false,
9548            },
9549        );
9550        fragments.insert(
9551            "ifInner".to_string(),
9552            FragmentList {
9553                fragments: vec![
9554                    WebUIFragment::raw("<p>"),
9555                    WebUIFragment::signal("inner_item.value", false),
9556                    WebUIFragment::raw("</p>"),
9557                ],
9558                contains_boundary: false,
9559            },
9560        );
9561        let protocol = WebUIProtocol::new(fragments);
9562        let state = test_json!({
9563            "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
9564            "inner_item": {"flag": true}
9565        });
9566        let mut writer = TestWriter::new();
9567        handle(
9568            &protocol,
9569            &state,
9570            &RenderOptions::new("index.html", "/"),
9571            &mut writer,
9572        )
9573        .unwrap();
9574        assert_eq!(
9575            writer.get_content(),
9576            "<div><section><p>X</p><p>Y</p></section></div>"
9577        );
9578    }
9579
9580    #[test]
9581    fn test_if_mixed_for_monikers() {
9582        let mut fragments = HashMap::new();
9583        fragments.insert(
9584            "index.html".to_string(),
9585            FragmentList {
9586                fragments: vec![
9587                    WebUIFragment::raw("<div>"),
9588                    WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
9589                    WebUIFragment::raw("</div>"),
9590                ],
9591                contains_boundary: false,
9592            },
9593        );
9594        fragments.insert(
9595            "outerTemplate".to_string(),
9596            FragmentList {
9597                fragments: vec![
9598                    WebUIFragment::raw("<section>"),
9599                    WebUIFragment::signal("outer.outerLabel", false),
9600                    WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
9601                    WebUIFragment::raw("</section>"),
9602                ],
9603                contains_boundary: false,
9604            },
9605        );
9606        fragments.insert(
9607            "innerTemplate".to_string(),
9608            FragmentList {
9609                fragments: vec![WebUIFragment::if_cond(
9610                    ConditionExpr::compound(
9611                        ConditionExpr::identifier("outer.active"),
9612                        LogicalOperator::And,
9613                        ConditionExpr::predicate(
9614                            "inner.value",
9615                            ComparisonOperator::GreaterThan,
9616                            "globalLimit",
9617                        ),
9618                    ),
9619                    "ifInner",
9620                )],
9621                contains_boundary: false,
9622            },
9623        );
9624        fragments.insert(
9625            "ifInner".to_string(),
9626            FragmentList {
9627                fragments: vec![
9628                    WebUIFragment::raw("<p>"),
9629                    WebUIFragment::signal("inner.value", false),
9630                    WebUIFragment::raw("</p>"),
9631                ],
9632                contains_boundary: false,
9633            },
9634        );
9635        let protocol = WebUIProtocol::new(fragments);
9636        let state = test_json!({
9637            "globalLimit": 10,
9638            "list": {"outerItems": [
9639                {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
9640                {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
9641                {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
9642            ]}
9643        });
9644        let mut writer = TestWriter::new();
9645        handle(
9646            &protocol,
9647            &state,
9648            &RenderOptions::new("index.html", "/"),
9649            &mut writer,
9650        )
9651        .unwrap();
9652        assert_eq!(
9653            writer.get_content(),
9654            "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
9655        );
9656    }
9657
9658    // ── Route-aware rendering tests ─────────────────────────────────────
9659
9660    fn make_route_protocol() -> WebUIProtocol {
9661        use webui_protocol::WebUiFragmentRoute;
9662
9663        let mut fragments = HashMap::new();
9664
9665        // Entry page with two routes
9666        fragments.insert(
9667            "index.html".to_string(),
9668            FragmentList {
9669                fragments: vec![
9670                    WebUIFragment::raw("<h1>Shell</h1>"),
9671                    WebUIFragment::route_from(WebUiFragmentRoute {
9672                        path: "/".into(),
9673                        fragment_id: "dash-page".into(),
9674                        exact: true,
9675                        keep_alive: false,
9676                        ..Default::default()
9677                    }),
9678                    WebUIFragment::route_from(WebUiFragmentRoute {
9679                        path: "/contacts/:id".into(),
9680                        fragment_id: "detail-page".into(),
9681                        exact: true,
9682                        keep_alive: true,
9683                        ..Default::default()
9684                    }),
9685                ],
9686
9687                contains_boundary: false,
9688            },
9689        );
9690
9691        // Dashboard page component
9692        fragments.insert(
9693            "dash-page".to_string(),
9694            FragmentList {
9695                fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
9696                contains_boundary: false,
9697            },
9698        );
9699
9700        // Detail page component
9701        fragments.insert(
9702            "detail-page".to_string(),
9703            FragmentList {
9704                fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
9705                contains_boundary: false,
9706            },
9707        );
9708
9709        WebUIProtocol::new(fragments)
9710    }
9711
9712    fn make_nested_route_protocol() -> WebUIProtocol {
9713        use webui_protocol::WebUiFragmentRoute;
9714
9715        let mut fragments = HashMap::new();
9716
9717        fragments.insert(
9718            "index.html".to_string(),
9719            FragmentList {
9720                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
9721                    path: "/".into(),
9722                    fragment_id: "app-shell".into(),
9723                    exact: false,
9724                    children: vec![WebUiFragmentRoute {
9725                        path: "sections/:id".into(),
9726                        fragment_id: "section-comp".into(),
9727                        exact: false,
9728                        children: vec![WebUiFragmentRoute {
9729                            path: "topics/:topicId".into(),
9730                            fragment_id: "topic-comp".into(),
9731                            exact: true,
9732                            children: vec![],
9733                            keep_alive: false,
9734                            ..Default::default()
9735                        }],
9736                        keep_alive: false,
9737                        ..Default::default()
9738                    }],
9739                    keep_alive: false,
9740                    ..Default::default()
9741                })],
9742
9743                contains_boundary: false,
9744            },
9745        );
9746
9747        fragments.insert(
9748            "app-shell".to_string(),
9749            FragmentList {
9750                fragments: vec![
9751                    WebUIFragment::raw("<h1>Shell</h1>"),
9752                    WebUIFragment::outlet(),
9753                ],
9754                contains_boundary: false,
9755            },
9756        );
9757
9758        fragments.insert(
9759            "section-comp".to_string(),
9760            FragmentList {
9761                fragments: vec![
9762                    WebUIFragment::raw("<h2>Section</h2>"),
9763                    WebUIFragment::outlet(),
9764                ],
9765                contains_boundary: false,
9766            },
9767        );
9768
9769        fragments.insert(
9770            "topic-comp".to_string(),
9771            FragmentList {
9772                fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
9773                contains_boundary: false,
9774            },
9775        );
9776
9777        WebUIProtocol::new(fragments)
9778    }
9779
9780    #[test]
9781    fn test_route_renders_shell_always() {
9782        let protocol = make_route_protocol();
9783        let state = test_json!({});
9784        let mut writer = TestWriter::new();
9785        handle(
9786            &protocol,
9787            &state,
9788            &RenderOptions::new("index.html", "/"),
9789            &mut writer,
9790        )
9791        .unwrap();
9792        let html = writer.get_content();
9793
9794        // Shell content always renders regardless of route matching
9795        assert!(html.contains("<h1>Shell</h1>"), "shell should render");
9796        // Dashboard matches "/" so it should be active
9797        assert!(html.contains(" active>"), "matched route should be active");
9798        // Detail should be hidden and empty
9799        assert!(
9800            html.contains("style=\"display:none\""),
9801            "non-matched routes should be hidden"
9802        );
9803    }
9804
9805    #[test]
9806    fn test_route_matched_renders_visible() {
9807        let protocol = make_route_protocol();
9808        let state = test_json!({});
9809        let mut writer = TestWriter::new();
9810        handle(
9811            &protocol,
9812            &state,
9813            &RenderOptions::new("index.html", "/"),
9814            &mut writer,
9815        )
9816        .unwrap();
9817        let html = writer.get_content();
9818
9819        // Dashboard route should be visible (active, no display:none)
9820        assert!(
9821            html.contains("<webui-route path=\"/\""),
9822            "dashboard route should exist"
9823        );
9824        assert!(
9825            html.contains("active>") && html.contains("<dash-page"),
9826            "matched route should be active with component tag: {html}"
9827        );
9828        assert!(
9829            html.contains("<p>Dashboard</p>"),
9830            "matched route should have content"
9831        );
9832    }
9833
9834    #[test]
9835    fn test_route_non_matched_renders_hidden_empty() {
9836        let protocol = make_route_protocol();
9837        let state = test_json!({});
9838        let mut writer = TestWriter::new();
9839        handle(
9840            &protocol,
9841            &state,
9842            &RenderOptions::new("index.html", "/"),
9843            &mut writer,
9844        )
9845        .unwrap();
9846        let html = writer.get_content();
9847
9848        // Detail route should be hidden and empty (no content rendered)
9849        assert!(
9850            html.contains("<webui-route path=\"/contacts/:id\""),
9851            "detail route element should exist"
9852        );
9853        // The non-matched route should have display:none and no inner content
9854        let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
9855        let after_detail = &html[detail_start..];
9856        assert!(
9857            after_detail.contains("style=\"display:none\">")
9858                && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
9859            "non-matched route should be hidden: {after_detail}"
9860        );
9861        // Should NOT contain the component's rendered content
9862        let detail_end = after_detail.find("</webui-route>").expect("closing tag");
9863        let detail_body = &after_detail[..detail_end];
9864        assert!(
9865            !detail_body.contains("<detail-page"),
9866            "non-matched route should not render component content: {detail_body}"
9867        );
9868    }
9869
9870    #[test]
9871    fn test_route_parameterized_match() {
9872        let protocol = make_route_protocol();
9873        let state = test_json!({});
9874        let mut writer = TestWriter::new();
9875        handle(
9876            &protocol,
9877            &state,
9878            &RenderOptions::new("index.html", "/contacts/42"),
9879            &mut writer,
9880        )
9881        .unwrap();
9882        let html = writer.get_content();
9883
9884        // Detail route matches /contacts/42
9885        assert!(
9886            html.contains("active>") && html.contains("<detail-page"),
9887            "detail route should be active: {html}"
9888        );
9889        assert!(html.contains("<p>Detail</p>"), "detail should have content");
9890        // Dashboard should be hidden + empty
9891        let dash_start = html
9892            .find("component=\"dash-page\"")
9893            .expect("dashboard route");
9894        let after_dash = &html[dash_start..];
9895        assert!(
9896            after_dash.contains("style=\"display:none\">"),
9897            "dashboard should be hidden when detail matches: {after_dash}"
9898        );
9899        let dash_end = after_dash.find("</webui-route>").expect("closing tag");
9900        let dash_body = &after_dash[..dash_end];
9901        assert!(
9902            !dash_body.contains("<dash-page"),
9903            "dashboard should not render component content: {dash_body}"
9904        );
9905    }
9906
9907    #[test]
9908    fn test_route_no_match_all_hidden_empty() {
9909        let protocol = make_route_protocol();
9910        let state = test_json!({});
9911        let mut writer = TestWriter::new();
9912        handle(
9913            &protocol,
9914            &state,
9915            &RenderOptions::new("index.html", "/nonexistent"),
9916            &mut writer,
9917        )
9918        .unwrap();
9919        let html = writer.get_content();
9920
9921        // Shell content should still render
9922        assert!(html.contains("<h1>Shell</h1>"));
9923        // All routes should be hidden + empty (nothing matched)
9924        assert!(
9925            !html.contains("<p>Dashboard</p>"),
9926            "no route content when nothing matches"
9927        );
9928        assert!(
9929            !html.contains("<p>Detail</p>"),
9930            "no route content when nothing matches"
9931        );
9932    }
9933
9934    #[test]
9935    fn test_route_component_attr_emitted() {
9936        let protocol = make_route_protocol();
9937        let state = test_json!({});
9938        let mut writer = TestWriter::new();
9939        handle(
9940            &protocol,
9941            &state,
9942            &RenderOptions::new("index.html", "/"),
9943            &mut writer,
9944        )
9945        .unwrap();
9946        let html = writer.get_content();
9947        // component attribute should be emitted on webui-route
9948        assert!(
9949            html.contains("component=\"dash-page\""),
9950            "component attr should be on webui-route: {html}"
9951        );
9952        assert!(
9953            html.contains("component=\"detail-page\""),
9954            "component attr should be on webui-route: {html}"
9955        );
9956        assert!(
9957            html.contains(r#"component="detail-page" exact keep-alive style="display:none">"#),
9958            "keep-alive should be emitted on an unmatched destination placeholder: {html}"
9959        );
9960    }
9961
9962    #[test]
9963    fn test_no_plugin_no_state_attributes() {
9964        let protocol = make_route_protocol();
9965        let state = test_json!({
9966            "title": "Fish & Chips",
9967            "cartOpen": true,
9968            "items": [{"name": "A&B"}]
9969        });
9970        let mut writer = TestWriter::new();
9971
9972        handle(
9973            &protocol,
9974            &state,
9975            &RenderOptions::new("index.html", "/"),
9976            &mut writer,
9977        )
9978        .unwrap();
9979
9980        let html = writer.get_content();
9981        // Without a plugin, no state attributes at all
9982        assert!(
9983            !html.contains("data-state"),
9984            "no data-state without plugin: {html}"
9985        );
9986        assert!(
9987            !html.contains(r#"title="Fish"#),
9988            "no scalar attrs without plugin: {html}"
9989        );
9990    }
9991
9992    #[test]
9993    fn test_nested_routes_render_webui_route_as_light_dom() {
9994        let protocol = make_nested_route_protocol();
9995        let state = test_json!({"title": "Test"});
9996        let handler = WebUIHandler::new();
9997        let mut writer = TestWriter::new();
9998
9999        handler
10000            .handle(
10001                &protocol,
10002                &state,
10003                &RenderOptions::new("index.html", "/sections/frontend"),
10004                &mut writer,
10005            )
10006            .expect("render failed");
10007
10008        let html = writer.get_content();
10009
10010        assert!(
10011            html.contains("component=\"app-shell\"") && html.contains("active>"),
10012            "root route should be active: {html}"
10013        );
10014        // webui-route should NOT have shadow DOM — it's a light DOM structural element
10015        assert!(
10016            !html.contains("<template shadowrootmode"),
10017            "webui-route should be light DOM (no shadow template): {html}"
10018        );
10019    }
10020
10021    #[test]
10022    fn test_nested_routes_render_outlet_as_light_dom() {
10023        let protocol = make_nested_route_protocol();
10024        let state = test_json!({"title": "Test"});
10025        let handler = WebUIHandler::new();
10026        let mut writer = TestWriter::new();
10027
10028        handler
10029            .handle(
10030                &protocol,
10031                &state,
10032                &RenderOptions::new("index.html", "/sections/frontend"),
10033                &mut writer,
10034            )
10035            .expect("render failed");
10036
10037        let html = writer.get_content();
10038
10039        // No <webui-outlet> wrapper — routes render directly at outlet position
10040        assert!(
10041            !html.contains("<webui-outlet>"),
10042            "should not contain webui-outlet wrapper: {html}"
10043        );
10044        // Route elements should be in the output directly
10045        assert!(
10046            html.contains("<webui-route"),
10047            "should contain webui-route elements: {html}"
10048        );
10049    }
10050
10051    #[test]
10052    fn test_nested_routes_match_child_at_outlet() {
10053        let protocol = make_nested_route_protocol();
10054        let state = test_json!({"title": "Test"});
10055        let handler = WebUIHandler::new();
10056        let mut writer = TestWriter::new();
10057
10058        handler
10059            .handle(
10060                &protocol,
10061                &state,
10062                &RenderOptions::new("index.html", "/sections/frontend"),
10063                &mut writer,
10064            )
10065            .expect("render failed");
10066
10067        let html = writer.get_content();
10068
10069        assert!(
10070            html.contains("component=\"section-comp\"") && html.contains("active>"),
10071            "section route should be active: {html}"
10072        );
10073        assert!(
10074            html.contains("<h2>Section</h2>"),
10075            "section content should be present: {html}"
10076        );
10077    }
10078
10079    #[test]
10080    fn test_nested_routes_three_levels_deep() {
10081        let protocol = make_nested_route_protocol();
10082        let state = test_json!({"title": "Test"});
10083        let handler = WebUIHandler::new();
10084        let mut writer = TestWriter::new();
10085
10086        handler
10087            .handle(
10088                &protocol,
10089                &state,
10090                &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
10091                &mut writer,
10092            )
10093            .expect("render failed");
10094
10095        let html = writer.get_content();
10096
10097        assert!(
10098            html.contains("component=\"app-shell\"") && html.contains("active>"),
10099            "root active: {html}"
10100        );
10101        assert!(
10102            html.contains("component=\"section-comp\"") && html.contains("active>"),
10103            "section active: {html}"
10104        );
10105        assert!(
10106            html.contains("component=\"topic-comp\"")
10107                && html.contains("exact")
10108                && html.contains("active>"),
10109            "topic active: {html}"
10110        );
10111        assert!(
10112            html.contains("<p>Topic content</p>"),
10113            "leaf content present: {html}"
10114        );
10115    }
10116
10117    #[test]
10118    fn test_nested_routes_nonmatched_siblings_hidden() {
10119        let protocol = make_nested_route_protocol();
10120        let state = test_json!({"title": "Test"});
10121        let handler = WebUIHandler::new();
10122        let mut writer = TestWriter::new();
10123
10124        handler
10125            .handle(
10126                &protocol,
10127                &state,
10128                &RenderOptions::new("index.html", "/sections/frontend"),
10129                &mut writer,
10130            )
10131            .expect("render failed");
10132
10133        let html = writer.get_content();
10134
10135        assert!(
10136            html.contains(r#"component="topic-comp" exact style="display:none">"#),
10137            "topic should be hidden: {html}"
10138        );
10139    }
10140
10141    #[test]
10142    fn test_nested_routes_preserve_sibling_declaration_order() {
10143        let mut protocol = make_nested_route_protocol();
10144        let Some(Fragment::Route(root)) = protocol
10145            .fragments
10146            .get_mut("index.html")
10147            .and_then(|entry| entry.fragments.first_mut())
10148            .and_then(|fragment| fragment.fragment.as_mut())
10149        else {
10150            panic!("nested route fixture must have a root route");
10151        };
10152        root.children.insert(
10153            0,
10154            webui_protocol::WebUiFragmentRoute {
10155                path: String::new(),
10156                fragment_id: "topic-comp".into(),
10157                exact: true,
10158                ..Default::default()
10159            },
10160        );
10161        root.children.push(webui_protocol::WebUiFragmentRoute {
10162            path: "*rest".into(),
10163            fragment_id: "topic-comp".into(),
10164            exact: true,
10165            ..Default::default()
10166        });
10167
10168        for path in ["/", "/sections/frontend", "/missing"] {
10169            let mut writer = TestWriter::new();
10170            WebUIHandler::new()
10171                .handle(
10172                    &protocol,
10173                    &test_json!({}),
10174                    &RenderOptions::new("index.html", path),
10175                    &mut writer,
10176                )
10177                .expect("render failed");
10178            let html = writer.get_content();
10179            let first = html.find(r#"<webui-route path="""#).expect("empty route");
10180            let middle = html
10181                .find(r#"<webui-route path="sections/:id""#)
10182                .expect("section route");
10183            let last = html
10184                .find(r#"<webui-route path="*rest""#)
10185                .expect("catch-all route");
10186            assert!(first < middle && middle < last, "{path}: {html}");
10187        }
10188    }
10189
10190    #[test]
10191    fn test_nested_routes_root_only() {
10192        let protocol = make_nested_route_protocol();
10193        let state = test_json!({"title": "Test"});
10194        let handler = WebUIHandler::new();
10195        let mut writer = TestWriter::new();
10196
10197        handler
10198            .handle(
10199                &protocol,
10200                &state,
10201                &RenderOptions::new("index.html", "/"),
10202                &mut writer,
10203            )
10204            .expect("render failed");
10205
10206        let html = writer.get_content();
10207
10208        assert!(
10209            html.contains("component=\"app-shell\"") && html.contains("active>"),
10210            "root active at /: {html}"
10211        );
10212        assert!(
10213            html.contains("<h1>Shell</h1>"),
10214            "shell renders at /: {html}"
10215        );
10216        assert!(
10217            html.contains(r#"component="section-comp" style="display:none">"#),
10218            "section hidden at /: {html}"
10219        );
10220    }
10221
10222    // ── CSS Module dedup tests ───────────────────────────────────────
10223
10224    #[test]
10225    fn test_css_module_emitted_once_inline_in_component() {
10226        // CSS module definition emitted once in the component's light DOM
10227        // on first render, not in <head> and not on second instance.
10228        let template = r#"<p><slot></slot></p>"#;
10229
10230        let mut fragments = HashMap::new();
10231        fragments.insert(
10232            "index.html".to_string(),
10233            FragmentList {
10234                fragments: vec![
10235                    WebUIFragment::raw("<html><head>".to_string()),
10236                    structural_fragment("head_end"),
10237                    WebUIFragment::raw("</head><body><div>".to_string()),
10238                    WebUIFragment::component("my-card"),
10239                    WebUIFragment::raw("A".to_string()),
10240                    WebUIFragment::component("my-card"),
10241                    WebUIFragment::raw("B</div>".to_string()),
10242                    structural_fragment("body_end"),
10243                    WebUIFragment::raw("</body></html>".to_string()),
10244                ],
10245                contains_boundary: false,
10246            },
10247        );
10248        fragments.insert(
10249            "my-card".to_string(),
10250            FragmentList {
10251                fragments: vec![WebUIFragment::raw(template.to_string())],
10252                contains_boundary: false,
10253            },
10254        );
10255
10256        let mut protocol = WebUIProtocol::new(fragments);
10257        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
10258        protocol
10259            .components
10260            .entry("my-card".to_string())
10261            .or_default()
10262            .css = "p{color:red}".to_string();
10263        protocol.populate_style_closures(&["index.html"]);
10264        let state = test_json!({});
10265        let mut writer = TestWriter::new();
10266
10267        handle(
10268            &protocol,
10269            &state,
10270            &RenderOptions::new("index.html", "/"),
10271            &mut writer,
10272        )
10273        .unwrap();
10274
10275        let html = writer.get_content();
10276
10277        // CSS module importmap should appear exactly once
10278        let count = html.matches(r#"<script type="importmap""#).count();
10279        assert_eq!(
10280            count, 1,
10281            "CSS module importmap should be emitted once, got {count} in: {html}"
10282        );
10283        assert!(
10284            html.contains(r#""my-card":"data:text/css,"#),
10285            "Importmap must register my-card under a data: URI: {html}"
10286        );
10287
10288        // Template content should appear twice (once per component instance)
10289        let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
10290        assert_eq!(
10291            tmpl_count, 2,
10292            "Template should render twice, got {tmpl_count} in: {html}"
10293        );
10294
10295        // CSS module should be in <body> (inline), not in <head>
10296        let css_pos = html
10297            .find(r#"<script type="importmap""#)
10298            .expect("CSS module importmap missing");
10299        let body_pos = html.find("<body>").expect("<body> missing");
10300        assert!(
10301            css_pos > body_pos,
10302            "CSS module should be inline in component, not in <head>: {html}"
10303        );
10304    }
10305
10306    #[test]
10307    fn test_component_without_css_renders_normally() {
10308        // Components without CSS module prefix pass through unchanged
10309        let mut fragments = HashMap::new();
10310        fragments.insert(
10311            "index.html".to_string(),
10312            FragmentList {
10313                fragments: vec![WebUIFragment::component("my-card")],
10314                contains_boundary: false,
10315            },
10316        );
10317        fragments.insert(
10318            "my-card".to_string(),
10319            FragmentList {
10320                fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
10321                contains_boundary: false,
10322            },
10323        );
10324
10325        let protocol = WebUIProtocol::new(fragments);
10326        let state = test_json!({});
10327        let mut writer = TestWriter::new();
10328
10329        handle(
10330            &protocol,
10331            &state,
10332            &RenderOptions::new("index.html", "/"),
10333            &mut writer,
10334        )
10335        .unwrap();
10336
10337        let html = writer.get_content();
10338        assert!(
10339            html.contains("<p>hello</p>"),
10340            "Non-module component should render normally: {html}"
10341        );
10342    }
10343
10344    #[test]
10345    fn test_non_module_strategy_no_css_in_head() {
10346        // When component_css is empty (Link/Style strategies), no
10347        // CSS module importmap tags should appear in <head>.
10348        let template = r#"<p>hello</p>"#;
10349
10350        let mut fragments = HashMap::new();
10351        fragments.insert(
10352            "index.html".to_string(),
10353            FragmentList {
10354                fragments: vec![
10355                    WebUIFragment::raw("<html><head>".to_string()),
10356                    structural_fragment("head_end"),
10357                    WebUIFragment::raw("</head><body>".to_string()),
10358                    WebUIFragment::component("my-card"),
10359                    WebUIFragment::raw("</body></html>".to_string()),
10360                ],
10361                contains_boundary: false,
10362            },
10363        );
10364        fragments.insert(
10365            "my-card".to_string(),
10366            FragmentList {
10367                fragments: vec![WebUIFragment::raw(template.to_string())],
10368                contains_boundary: false,
10369            },
10370        );
10371
10372        // No component css populated — simulates Link/Style strategy
10373        let protocol = WebUIProtocol::new(fragments);
10374        let state = test_json!({});
10375        let mut writer = TestWriter::new();
10376
10377        handle(
10378            &protocol,
10379            &state,
10380            &RenderOptions::new("index.html", "/"),
10381            &mut writer,
10382        )
10383        .unwrap();
10384
10385        let html = writer.get_content();
10386
10387        assert!(
10388            !html.contains(r#"<style type="module""#),
10389            "Non-module strategy should not emit CSS module tags in <head>: {html}"
10390        );
10391        assert!(
10392            !html.contains(r#"<script type="importmap""#),
10393            "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
10394        );
10395        assert!(
10396            html.contains("<p>hello</p>"),
10397            "Component should still render: {html}"
10398        );
10399    }
10400
10401    #[test]
10402    fn test_style_strategy_embeds_inline_style_in_shadow_template() {
10403        let mut fragments = HashMap::new();
10404        fragments.insert(
10405            "index.html".to_string(),
10406            FragmentList {
10407                fragments: vec![
10408                    WebUIFragment::raw("<html><head>".to_string()),
10409                    structural_fragment("head_end"),
10410                    WebUIFragment::raw("</head><body><my-card>".to_string()),
10411                    WebUIFragment::component("my-card"),
10412                    WebUIFragment::raw("</my-card></body></html>".to_string()),
10413                ],
10414                contains_boundary: false,
10415            },
10416        );
10417        fragments.insert(
10418            "my-card".to_string(),
10419            FragmentList {
10420                fragments: vec![WebUIFragment::raw(
10421                    "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
10422                        .to_string(),
10423                )],
10424                        contains_boundary: false,
10425},
10426        );
10427
10428        let protocol = WebUIProtocol::new(fragments);
10429        let state = test_json!({});
10430        let mut writer = TestWriter::new();
10431
10432        handle(
10433            &protocol,
10434            &state,
10435            &RenderOptions::new("index.html", "/"),
10436            &mut writer,
10437        )
10438        .unwrap();
10439
10440        let html = writer.get_content();
10441
10442        assert!(
10443            html.contains("<style>.card{color:red}</style>"),
10444            "Style strategy should embed inline CSS in shadow template: {html}"
10445        );
10446        assert!(
10447            !html.contains(r#"<style type="module""#),
10448            "Style strategy should not emit legacy module CSS in <head>: {html}"
10449        );
10450        assert!(
10451            !html.contains(r#"<script type="importmap""#),
10452            "Style strategy should not emit CSS module importmaps in <head>: {html}"
10453        );
10454    }
10455
10456    #[test]
10457    fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
10458        // Light DOM + Link strategy: handler emits <link rel="stylesheet">
10459        // in <head>. No preload tag — the stylesheet itself fetches.
10460        let mut fragments = HashMap::new();
10461        fragments.insert(
10462            "index.html".to_string(),
10463            FragmentList {
10464                fragments: vec![
10465                    WebUIFragment::raw("<html><head>".to_string()),
10466                    structural_fragment("head_end"),
10467                    WebUIFragment::raw("</head><body><my-card>".to_string()),
10468                    WebUIFragment::component("my-card"),
10469                    WebUIFragment::raw("</my-card>".to_string()),
10470                    structural_fragment("body_end"),
10471                    WebUIFragment::raw("</body></html>".to_string()),
10472                ],
10473                contains_boundary: false,
10474            },
10475        );
10476        fragments.insert(
10477            "my-card".to_string(),
10478            FragmentList {
10479                fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
10480                contains_boundary: false,
10481            },
10482        );
10483
10484        let mut protocol = WebUIProtocol::new(fragments);
10485        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10486
10487        let comp = protocol
10488            .components
10489            .entry("my-card".to_string())
10490            .or_default();
10491        comp.css_href = "my-card.css".to_string();
10492        comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
10493        protocol.populate_style_closures(&["index.html"]);
10494
10495        let state = test_json!({});
10496        let mut writer = TestWriter::new();
10497
10498        handle(
10499            &protocol,
10500            &state,
10501            &RenderOptions::new("index.html", "/"),
10502            &mut writer,
10503        )
10504        .unwrap();
10505
10506        let html = writer.get_content();
10507
10508        let head_end = html.find("</head>").expect("</head> missing");
10509        let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css""#);
10510        assert!(
10511            link_pos.is_some_and(|p| p < head_end),
10512            "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
10513        );
10514        assert!(
10515            !html.contains(r#"<link rel="preload""#),
10516            "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
10517        );
10518        assert!(
10519            !html.contains(r#"<style type="module""#),
10520            "Link strategy should not emit legacy module CSS: {html}"
10521        );
10522        assert!(
10523            !html.contains(r#"<script type="importmap""#),
10524            "Link strategy should not emit CSS module importmaps: {html}"
10525        );
10526    }
10527
10528    #[test]
10529    fn link_strategy_emits_stylesheets_in_deterministic_document_order() {
10530        // Regression: `css_hrefs`/`style_specs` must follow the traversal-ordered
10531        // `reachable` list (a `Vec<String>`), never a `HashSet<String>` — whose
10532        // iteration order depends on the process's randomized hash seed and can
10533        // silently reorder `<link>` tags between renders/process restarts, which
10534        // in turn can flip cascade-order-sensitive CSS (e.g. same-specificity
10535        // `order`/`position` rules) and move on-page elements around.
10536        let mut fragments = HashMap::new();
10537        fragments.insert(
10538            "index.html".to_string(),
10539            FragmentList {
10540                fragments: vec![
10541                    WebUIFragment::raw("<html><head>".to_string()),
10542                    structural_fragment("head_end"),
10543                    WebUIFragment::raw(
10544                        "</head><body><comp-a></comp-a><comp-b></comp-b><comp-c></comp-c>"
10545                            .to_string(),
10546                    ),
10547                    WebUIFragment::component("comp-a"),
10548                    WebUIFragment::component("comp-b"),
10549                    WebUIFragment::component("comp-c"),
10550                    structural_fragment("body_end"),
10551                    WebUIFragment::raw("</body></html>".to_string()),
10552                ],
10553                contains_boundary: false,
10554            },
10555        );
10556        for tag in ["comp-a", "comp-b", "comp-c"] {
10557            fragments.insert(
10558                tag.to_string(),
10559                FragmentList {
10560                    fragments: vec![WebUIFragment::raw(format!("<div>{tag}</div>"))],
10561                    contains_boundary: false,
10562                },
10563            );
10564        }
10565
10566        let mut protocol = WebUIProtocol::new(fragments);
10567        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10568        for tag in ["comp-a", "comp-b", "comp-c"] {
10569            let comp = protocol.components.entry(tag.to_string()).or_default();
10570            comp.css_href = format!("{tag}.css");
10571            comp.template_json = format!(r#"{{"h":"<div>{tag}</div>"}}"#);
10572        }
10573        protocol.populate_style_closures(&["index.html"]);
10574
10575        let state = test_json!({});
10576        let mut writer = TestWriter::new();
10577        handle(
10578            &protocol,
10579            &state,
10580            &RenderOptions::new("index.html", "/"),
10581            &mut writer,
10582        )
10583        .unwrap();
10584        let html = writer.get_content();
10585
10586        let pos_a = html
10587            .find(r#"href="comp-a.css""#)
10588            .expect("comp-a.css link missing");
10589        let pos_b = html
10590            .find(r#"href="comp-b.css""#)
10591            .expect("comp-b.css link missing");
10592        let pos_c = html
10593            .find(r#"href="comp-c.css""#)
10594            .expect("comp-c.css link missing");
10595        assert!(
10596            pos_a < pos_b && pos_b < pos_c,
10597            "stylesheet <link> tags must follow document/traversal order \
10598             (comp-a, comp-b, comp-c), got positions {pos_a}, {pos_b}, {pos_c}: {html}"
10599        );
10600    }
10601
10602    #[test]
10603    fn link_strategy_preloads_static_shadow_css_in_head() {
10604        let mut fragments = HashMap::new();
10605        fragments.insert(
10606            "index.html".to_string(),
10607            FragmentList {
10608                fragments: vec![
10609                    WebUIFragment::raw("<html><head>".to_string()),
10610                    structural_fragment("head_end"),
10611                    WebUIFragment::raw("</head><body><my-card>".to_string()),
10612                    WebUIFragment::component("my-card"),
10613                    WebUIFragment::raw("</my-card></body></html>".to_string()),
10614                ],
10615                contains_boundary: false,
10616            },
10617        );
10618        fragments.insert(
10619            "o-loading-state".to_string(),
10620            FragmentList {
10621                fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
10622                contains_boundary: false,
10623            },
10624        );
10625        fragments.insert(
10626            "my-card".to_string(),
10627            FragmentList {
10628                fragments: vec![
10629                    WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10630                    structural_fragment("shadow_styles:my-card"),
10631                    WebUIFragment::raw("<div>card</div></template>".to_string()),
10632                ],
10633                contains_boundary: false,
10634            },
10635        );
10636
10637        let mut protocol = WebUIProtocol::new(fragments);
10638        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10639        let component = protocol
10640            .components
10641            .entry("my-card".to_string())
10642            .or_default();
10643        component.css_href = "my-card.css".to_string();
10644        component.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
10645        component.uses_shadow_dom = true;
10646        protocol.populate_style_closures(&["index.html"]);
10647
10648        let mut writer = TestWriter::new();
10649        handle(
10650            &protocol,
10651            &test_json!({}),
10652            &RenderOptions::new("index.html", "/"),
10653            &mut writer,
10654        )
10655        .unwrap();
10656
10657        let html = writer.get_content();
10658        let head_end = html.find("</head>").expect("</head> missing");
10659        let preload = html
10660            .find(r#"<link rel="preload" as="style" href="my-card.css">"#)
10661            .expect("Shadow preload missing");
10662        let stylesheet = html
10663            .find(r#"<link rel="stylesheet" href="my-card.css""#)
10664            .expect("Shadow stylesheet missing");
10665        assert!(preload < head_end);
10666        assert!(stylesheet > head_end);
10667        assert_eq!(html.matches(r#"rel="preload" as="style""#).count(), 1);
10668    }
10669
10670    #[test]
10671    fn tree_local_styles_deduplicate_per_root_and_resume_caller_frame() {
10672        let fragments = HashMap::from([
10673            (
10674                "index.html".to_string(),
10675                FragmentList {
10676                    fragments: vec![
10677                        WebUIFragment::raw("<html><head>".to_string()),
10678                        structural_fragment("head_end"),
10679                        WebUIFragment::raw("</head><body><outer-box>".to_string()),
10680                        WebUIFragment::component("outer-box"),
10681                        WebUIFragment::raw("</outer-box></body></html>".to_string()),
10682                    ],
10683                    contains_boundary: false,
10684                },
10685            ),
10686            (
10687                "outer-box".to_string(),
10688                FragmentList {
10689                    fragments: vec![
10690                        WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10691                        structural_fragment("shadow_styles:outer-box"),
10692                        WebUIFragment::raw("<light-card>".to_string()),
10693                        WebUIFragment::component("light-card"),
10694                        WebUIFragment::raw("</light-card><light-card>".to_string()),
10695                        WebUIFragment::component("light-card"),
10696                        WebUIFragment::raw("</light-card><inner-box>".to_string()),
10697                        WebUIFragment::component("inner-box"),
10698                        WebUIFragment::raw("</inner-box><light-card>".to_string()),
10699                        WebUIFragment::component("light-card"),
10700                        WebUIFragment::raw("</light-card></template>".to_string()),
10701                    ],
10702                    contains_boundary: false,
10703                },
10704            ),
10705            (
10706                "inner-box".to_string(),
10707                FragmentList {
10708                    fragments: vec![
10709                        WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10710                        structural_fragment("shadow_styles:inner-box"),
10711                        WebUIFragment::raw("<light-card>".to_string()),
10712                        WebUIFragment::component("light-card"),
10713                        WebUIFragment::raw("</light-card></template>".to_string()),
10714                    ],
10715                    contains_boundary: false,
10716                },
10717            ),
10718            (
10719                "light-card".to_string(),
10720                FragmentList {
10721                    fragments: vec![WebUIFragment::raw("<p>card</p>".to_string())],
10722                    contains_boundary: false,
10723                },
10724            ),
10725        ]);
10726        let mut protocol = WebUIProtocol::new(fragments);
10727        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10728        for (tag, css, uses_shadow_dom) in [
10729            ("outer-box", ".outer{}", true),
10730            ("inner-box", ".inner{}", true),
10731            ("light-card", ".card{}", false),
10732        ] {
10733            let component = protocol.components.entry(tag.to_string()).or_default();
10734            component.css = css.to_string();
10735            component.uses_shadow_dom = uses_shadow_dom;
10736        }
10737        protocol.populate_style_closures(&["index.html"]);
10738
10739        let mut writer = TestWriter::new();
10740        handle(
10741            &protocol,
10742            &test_json!({}),
10743            &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10744            &mut writer,
10745        )
10746        .unwrap();
10747        let html = writer.get_content();
10748
10749        assert_eq!(html.matches("data-webui-resource=\"outer-box\"").count(), 1);
10750        assert_eq!(html.matches("data-webui-resource=\"inner-box\"").count(), 1);
10751        assert_eq!(
10752            html.matches("data-webui-resource=\"light-card\"").count(),
10753            2,
10754            "the Light resource installs once in each Shadow tree: {html}"
10755        );
10756        assert_eq!(
10757            html.matches("data-webui-resource=").count(),
10758            4,
10759            "outer-box + light-card and inner-box + light-card are the four tree-local installs"
10760        );
10761        assert_eq!(
10762            html.matches("nonce=\"css-nonce\"").count(),
10763            4,
10764            "each inline style resource carries the nonce"
10765        );
10766        assert!(html.contains(r#"<meta name="webui-nonce" content="css-nonce">"#));
10767        let inner_end = html.find("</inner-box>").expect("inner host close");
10768        let caller_continuation = &html[inner_end..];
10769        let trailing_card = caller_continuation
10770            .find("<p>card</p>")
10771            .expect("caller continues after nested Shadow root");
10772        assert!(trailing_card > 0);
10773        assert!(
10774            !caller_continuation.contains("data-webui-resource=\"light-card\""),
10775            "returning from the nested Shadow root must restore the outer frame's delivered set"
10776        );
10777    }
10778
10779    #[test]
10780    fn routed_document_styles_are_hoisted_for_the_active_chain() {
10781        let route = WebUiFragmentRoute {
10782            path: "/".to_string(),
10783            fragment_id: "app-shell".to_string(),
10784            children: vec![
10785                WebUiFragmentRoute {
10786                    path: String::new(),
10787                    fragment_id: "dashboard-page".to_string(),
10788                    exact: true,
10789                    ..Default::default()
10790                },
10791                WebUiFragmentRoute {
10792                    path: "details".to_string(),
10793                    fragment_id: "detail-page".to_string(),
10794                    exact: true,
10795                    ..Default::default()
10796                },
10797            ],
10798            ..Default::default()
10799        };
10800        let fragments = HashMap::from([
10801            (
10802                "index.html".to_string(),
10803                FragmentList {
10804                    fragments: vec![
10805                        WebUIFragment::raw("<html><head>"),
10806                        structural_fragment("head_start"),
10807                        structural_fragment("head_end"),
10808                        WebUIFragment::raw("</head><body>"),
10809                        structural_fragment("body_start"),
10810                        WebUIFragment::boundary(0, "index.html", "boundary-0", None),
10811                        WebUIFragment::route_from(route),
10812                        WebUIFragment::boundary_end(0),
10813                        structural_fragment("body_end"),
10814                        WebUIFragment::raw("</body></html>"),
10815                    ],
10816                    contains_boundary: true,
10817                },
10818            ),
10819            (
10820                "app-shell".to_string(),
10821                FragmentList {
10822                    fragments: vec![
10823                        WebUIFragment::raw("<header>App</header>"),
10824                        WebUIFragment::outlet(),
10825                    ],
10826                    contains_boundary: false,
10827                },
10828            ),
10829            (
10830                "dashboard-page".to_string(),
10831                FragmentList {
10832                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10833                    contains_boundary: false,
10834                },
10835            ),
10836            (
10837                "detail-page".to_string(),
10838                FragmentList {
10839                    fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
10840                    contains_boundary: false,
10841                },
10842            ),
10843        ]);
10844        let mut protocol = WebUIProtocol::new(fragments);
10845        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10846        for tag in ["app-shell", "dashboard-page", "detail-page"] {
10847            let component = protocol.components.entry(tag.to_string()).or_default();
10848            component.css_href = format!("/{tag}.css");
10849            component.template_json = r#"{"h":""}"#.to_string();
10850        }
10851        protocol.populate_style_closures(&["index.html"]);
10852
10853        let mut writer = TestWriter::new();
10854        handle(
10855            &protocol,
10856            &test_json!({}),
10857            &RenderOptions::new("index.html", "/"),
10858            &mut writer,
10859        )
10860        .unwrap();
10861        let html = writer.get_content();
10862
10863        assert_eq!(
10864            html.matches(r#"data-webui-resource="app-shell""#).count(),
10865            1,
10866            "the matched route shell must install once: {html}"
10867        );
10868        assert_eq!(
10869            html.matches(r#"data-webui-resource="dashboard-page""#)
10870                .count(),
10871            1,
10872            "the matched child route must install once: {html}"
10873        );
10874        assert!(
10875            !html.contains(r#"data-webui-resource="detail-page""#),
10876            "inactive route CSS must not be delivered: {html}"
10877        );
10878        let head_end = html.find("</head>").expect("head close");
10879        let head = &html[..head_end];
10880        for (resource, href) in [
10881            ("app-shell", "/app-shell.css"),
10882            ("dashboard-page", "/dashboard-page.css"),
10883        ] {
10884            let encoded_href = crate::html_encode::encode_safe(href);
10885            assert!(
10886                head.contains(&format!(
10887                    r#"<link rel="stylesheet" href="{encoded_href}" data-webui-resource="{resource}" data-webui-strategy="link">"#
10888                )),
10889                "Document-targeted route CSS must be render-blocking in head: {html}"
10890            );
10891            assert!(
10892                !head.contains(&format!(
10893                    r#"<link rel="preload" as="style" href="{encoded_href}">"#
10894                )),
10895                "a hoisted stylesheet must not also be preloaded: {html}"
10896            );
10897        }
10898        assert!(
10899            !head.contains("detail-page.css"),
10900            "inactive route CSS must not be preloaded: {html}"
10901        );
10902        let app_style = html
10903            .find(r#"data-webui-resource="app-shell""#)
10904            .expect("app style");
10905        let dashboard_style = html
10906            .find(r#"data-webui-resource="dashboard-page""#)
10907            .expect("dashboard style");
10908        let app_host = html.find("<app-shell").expect("app route host");
10909        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10910        assert!(
10911            app_style < dashboard_style
10912                && dashboard_style < head_end
10913                && head_end < app_host
10914                && app_host < dashboard_host,
10915            "matched Document route closures must be hoisted in chain order: {html}"
10916        );
10917
10918        let protocol = Protocol::new(protocol);
10919        let mut streamed = FlushTestWriter::default();
10920        WebUIHandler::new()
10921            .render_streaming(
10922                &protocol,
10923                &test_json!({}),
10924                &RenderOptions::new("index.html", "/"),
10925                &mut streamed,
10926            )
10927            .unwrap();
10928        let streamed_head_end = streamed
10929            .output
10930            .find("</head>")
10931            .expect("streamed head close");
10932        for resource in ["app-shell", "dashboard-page"] {
10933            let marker = format!(r#"data-webui-resource="{resource}""#);
10934            let position = streamed.output.find(&marker).expect("streamed route style");
10935            assert!(
10936                position < streamed_head_end,
10937                "streaming must hoist {resource} into head: {}",
10938                streamed.output
10939            );
10940            assert_eq!(
10941                streamed.output.matches(&marker).count(),
10942                1,
10943                "streaming must not emit {resource} twice: {}",
10944                streamed.output
10945            );
10946        }
10947    }
10948
10949    #[test]
10950    fn bundled_routed_document_stylesheet_is_hoisted_once() {
10951        let route = WebUiFragmentRoute {
10952            path: "/".to_string(),
10953            fragment_id: "dashboard-page".to_string(),
10954            exact: true,
10955            ..Default::default()
10956        };
10957        let mut protocol = WebUIProtocol::new(HashMap::from([
10958            (
10959                "index.html".to_string(),
10960                FragmentList {
10961                    fragments: vec![
10962                        WebUIFragment::raw("<html><head>"),
10963                        structural_fragment("head_start"),
10964                        structural_fragment("head_end"),
10965                        WebUIFragment::raw("</head><body>"),
10966                        WebUIFragment::route_from(route),
10967                        structural_fragment("body_end"),
10968                        WebUIFragment::raw("</body></html>"),
10969                    ],
10970                    contains_boundary: false,
10971                },
10972            ),
10973            (
10974                "dashboard-page".to_string(),
10975                FragmentList {
10976                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10977                    contains_boundary: false,
10978                },
10979            ),
10980        ]));
10981        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10982        for tag in ["dashboard-page", "summary-card", "activity-list"] {
10983            protocol
10984                .components
10985                .entry(tag.to_string())
10986                .or_default()
10987                .css_href = format!("/{tag}.css");
10988        }
10989        protocol.style_closures.insert(
10990            "index.html".to_string(),
10991            webui_protocol::ComponentStyleClosure::default(),
10992        );
10993        protocol.style_closures.insert(
10994            "dashboard-page".to_string(),
10995            webui_protocol::ComponentStyleClosure {
10996                component_tags: vec![
10997                    "dashboard-page".to_string(),
10998                    "summary-card".to_string(),
10999                    "activity-list".to_string(),
11000                ],
11001                style_chunks: vec![0],
11002            },
11003        );
11004        protocol.style_chunks.push(webui_protocol::StyleChunk {
11005            name: "_chunk-dashboard-page-3".to_string(),
11006            css: String::new(),
11007            css_href: "/_chunk-dashboard-page-3.css".to_string(),
11008            component_tags: vec![
11009                "dashboard-page".to_string(),
11010                "summary-card".to_string(),
11011                "activity-list".to_string(),
11012            ],
11013        });
11014
11015        let mut writer = TestWriter::new();
11016        handle(
11017            &protocol,
11018            &test_json!({}),
11019            &RenderOptions::new("index.html", "/"),
11020            &mut writer,
11021        )
11022        .unwrap();
11023        let html = writer.get_content();
11024        let head_end = html.find("</head>").expect("head close");
11025        let marker = r#"data-webui-resource="_chunk-dashboard-page-3""#;
11026        let style = html.find(marker).expect("bundled route stylesheet");
11027
11028        assert!(
11029            style < head_end,
11030            "bundled route CSS must be in head: {html}"
11031        );
11032        assert_eq!(
11033            html.matches(marker).count(),
11034            1,
11035            "the route host must not emit the hoisted chunk again: {html}"
11036        );
11037        let bundled_href = crate::html_encode::encode_safe("/_chunk-dashboard-page-3.css");
11038        assert!(
11039            !html.contains(&format!(
11040                r#"<link rel="preload" as="style" href="{bundled_href}">"#
11041            )),
11042            "the applied bundled stylesheet must not also be preloaded: {html}"
11043        );
11044    }
11045
11046    #[test]
11047    fn routed_document_inline_styles_are_hoisted() {
11048        for strategy in [
11049            webui_protocol::CssStrategy::Style,
11050            webui_protocol::CssStrategy::Module,
11051        ] {
11052            let route = WebUiFragmentRoute {
11053                path: "/".to_string(),
11054                fragment_id: "dashboard-page".to_string(),
11055                exact: true,
11056                ..Default::default()
11057            };
11058            let mut protocol = WebUIProtocol::new(HashMap::from([
11059                (
11060                    "index.html".to_string(),
11061                    FragmentList {
11062                        fragments: vec![
11063                            WebUIFragment::raw("<html><head>"),
11064                            structural_fragment("head_start"),
11065                            structural_fragment("head_end"),
11066                            WebUIFragment::raw("</head><body>"),
11067                            WebUIFragment::route_from(route),
11068                            structural_fragment("body_end"),
11069                            WebUIFragment::raw("</body></html>"),
11070                        ],
11071                        contains_boundary: false,
11072                    },
11073                ),
11074                (
11075                    "dashboard-page".to_string(),
11076                    FragmentList {
11077                        fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11078                        contains_boundary: false,
11079                    },
11080                ),
11081            ]));
11082            protocol.set_css_strategy(strategy);
11083            protocol
11084                .components
11085                .entry("dashboard-page".to_string())
11086                .or_default()
11087                .css = ".dashboard{display:grid}".to_string();
11088            protocol.populate_style_closures(&["index.html"]);
11089
11090            let mut writer = TestWriter::new();
11091            handle(
11092                &protocol,
11093                &test_json!({}),
11094                &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
11095                &mut writer,
11096            )
11097            .unwrap();
11098            let html = writer.get_content();
11099            let head_end = html.find("</head>").expect("head close");
11100            let marker = r#"data-webui-resource="dashboard-page""#;
11101            let style = html.find(marker).expect("route style fallback");
11102            let host = html.find("<dashboard-page").expect("dashboard route host");
11103            let strategy_name = if strategy == webui_protocol::CssStrategy::Module {
11104                "module"
11105            } else {
11106                "style"
11107            };
11108            let style_marker = format!(
11109                r#"data-webui-resource="dashboard-page" data-webui-strategy="{strategy_name}""#
11110            );
11111
11112            assert!(
11113                style < head_end && head_end < host,
11114                "{strategy:?} route CSS must be applied in head before its host: {html}"
11115            );
11116            assert_eq!(
11117                html.matches(&style_marker).count(),
11118                1,
11119                "{strategy:?} route CSS must not be emitted twice: {html}"
11120            );
11121        }
11122    }
11123
11124    #[test]
11125    fn light_route_inside_static_shadow_root_is_only_preloaded_in_head() {
11126        let route = WebUiFragmentRoute {
11127            path: "/".to_string(),
11128            fragment_id: "dashboard-page".to_string(),
11129            exact: true,
11130            ..Default::default()
11131        };
11132        let mut protocol = WebUIProtocol::new(HashMap::from([
11133            (
11134                "index.html".to_string(),
11135                FragmentList {
11136                    fragments: vec![
11137                        WebUIFragment::raw("<html><head>"),
11138                        structural_fragment("head_start"),
11139                        structural_fragment("head_end"),
11140                        WebUIFragment::raw("</head><body><outer-box>"),
11141                        WebUIFragment::component("outer-box"),
11142                        WebUIFragment::raw("</outer-box></body></html>"),
11143                    ],
11144                    contains_boundary: false,
11145                },
11146            ),
11147            (
11148                "outer-box".to_string(),
11149                FragmentList {
11150                    fragments: vec![
11151                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11152                        structural_fragment("shadow_styles:outer-box"),
11153                        WebUIFragment::route_from(route),
11154                        WebUIFragment::raw("</template>"),
11155                    ],
11156                    contains_boundary: false,
11157                },
11158            ),
11159            (
11160                "dashboard-page".to_string(),
11161                FragmentList {
11162                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11163                    contains_boundary: false,
11164                },
11165            ),
11166        ]));
11167        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11168        for tag in ["outer-box", "dashboard-page"] {
11169            protocol
11170                .components
11171                .entry(tag.to_string())
11172                .or_default()
11173                .css_href = format!("/{tag}.css");
11174        }
11175        protocol
11176            .components
11177            .get_mut("outer-box")
11178            .expect("outer-box component")
11179            .uses_shadow_dom = true;
11180        protocol.populate_style_closures(&["index.html"]);
11181
11182        let mut writer = TestWriter::new();
11183        handle(
11184            &protocol,
11185            &test_json!({}),
11186            &RenderOptions::new("index.html", "/"),
11187            &mut writer,
11188        )
11189        .unwrap();
11190        let html = writer.get_content();
11191        let head_end = html.find("</head>").expect("head close");
11192        let head = &html[..head_end];
11193        let dashboard_style = html
11194            .find(r#"data-webui-resource="dashboard-page""#)
11195            .expect("tree-local dashboard stylesheet");
11196        let shadow_start = html
11197            .find("<template shadowrootmode=\"open\">")
11198            .expect("Shadow template");
11199        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
11200        let dashboard_href = crate::html_encode::encode_safe("/dashboard-page.css");
11201
11202        assert!(
11203            head.contains(&format!(
11204                r#"<link rel="preload" as="style" href="{dashboard_href}">"#
11205            )),
11206            "Shadow-targeted route CSS must start loading from head: {html}"
11207        );
11208        assert!(
11209            !head.contains(r#"data-webui-resource="dashboard-page""#),
11210            "Shadow-targeted route CSS must not be applied to Document: {html}"
11211        );
11212        assert!(
11213            shadow_start < dashboard_style && dashboard_style < dashboard_host,
11214            "the applying stylesheet must remain inside the owning ShadowRoot: {html}"
11215        );
11216    }
11217
11218    #[test]
11219    fn isolated_shadow_entry_does_not_emit_document_preloads() {
11220        let route = WebUiFragmentRoute {
11221            path: "/".to_string(),
11222            fragment_id: "dashboard-page".to_string(),
11223            exact: true,
11224            ..Default::default()
11225        };
11226        let mut protocol = WebUIProtocol::new(HashMap::from([
11227            (
11228                "outer-box".to_string(),
11229                FragmentList {
11230                    fragments: vec![
11231                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11232                        structural_fragment("shadow_styles:outer-box"),
11233                        WebUIFragment::route_from(route),
11234                        WebUIFragment::raw("</template>"),
11235                    ],
11236                    contains_boundary: false,
11237                },
11238            ),
11239            (
11240                "dashboard-page".to_string(),
11241                FragmentList {
11242                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11243                    contains_boundary: false,
11244                },
11245            ),
11246        ]));
11247        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11248        for tag in ["outer-box", "dashboard-page"] {
11249            protocol
11250                .components
11251                .entry(tag.to_string())
11252                .or_default()
11253                .css_href = format!("/{tag}.css");
11254        }
11255        protocol
11256            .components
11257            .get_mut("outer-box")
11258            .expect("outer-box component")
11259            .uses_shadow_dom = true;
11260        protocol.populate_style_closures(&["outer-box"]);
11261
11262        let mut writer = TestWriter::new();
11263        handle(
11264            &protocol,
11265            &test_json!({}),
11266            &RenderOptions::new("outer-box", "/"),
11267            &mut writer,
11268        )
11269        .unwrap();
11270        let html = writer.get_content();
11271
11272        assert!(
11273            html.starts_with("<template shadowrootmode=\"open\">"),
11274            "isolated component output must not be prefixed with document metadata: {html}"
11275        );
11276        assert!(
11277            !html.contains(r#"<link rel="preload""#),
11278            "an isolated Shadow entry has no Document head for preloads: {html}"
11279        );
11280        assert!(
11281            html.contains(r#"data-webui-resource="dashboard-page""#),
11282            "the route stylesheet must still install inside the ShadowRoot: {html}"
11283        );
11284    }
11285
11286    #[test]
11287    fn routed_shadow_styles_stay_tree_local_and_deduplicate_static_resources() {
11288        let route = WebUiFragmentRoute {
11289            path: "/".to_string(),
11290            fragment_id: "app-shell".to_string(),
11291            children: vec![
11292                WebUiFragmentRoute {
11293                    path: String::new(),
11294                    fragment_id: "dashboard-page".to_string(),
11295                    exact: true,
11296                    ..Default::default()
11297                },
11298                WebUiFragmentRoute {
11299                    path: "details".to_string(),
11300                    fragment_id: "detail-page".to_string(),
11301                    exact: true,
11302                    ..Default::default()
11303                },
11304            ],
11305            ..Default::default()
11306        };
11307        let fragments = HashMap::from([
11308            (
11309                "index.html".to_string(),
11310                FragmentList {
11311                    fragments: vec![
11312                        WebUIFragment::raw("<html><head>"),
11313                        structural_fragment("head_start"),
11314                        structural_fragment("head_end"),
11315                        WebUIFragment::raw("</head><body>"),
11316                        structural_fragment("body_start"),
11317                        WebUIFragment::boundary(0, "index.html", "boundary-0", None),
11318                        WebUIFragment::route_from(route),
11319                        WebUIFragment::boundary_end(0),
11320                        structural_fragment("body_end"),
11321                        WebUIFragment::raw("</body></html>"),
11322                    ],
11323                    contains_boundary: true,
11324                },
11325            ),
11326            (
11327                "app-shell".to_string(),
11328                FragmentList {
11329                    fragments: vec![
11330                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11331                        structural_fragment("shadow_styles:app-shell"),
11332                        WebUIFragment::raw("<shared-card"),
11333                        structural_fragment("streaming_root:shared-card"),
11334                        WebUIFragment::raw(">"),
11335                        WebUIFragment::component("shared-card"),
11336                        WebUIFragment::raw("</shared-card>"),
11337                        WebUIFragment::outlet(),
11338                        WebUIFragment::raw("</template>"),
11339                    ],
11340                    contains_boundary: false,
11341                },
11342            ),
11343            (
11344                "dashboard-page".to_string(),
11345                FragmentList {
11346                    fragments: vec![
11347                        WebUIFragment::raw("<shared-card"),
11348                        structural_fragment("streaming_root:shared-card"),
11349                        WebUIFragment::raw(">"),
11350                        WebUIFragment::component("shared-card"),
11351                        WebUIFragment::raw("</shared-card><main>Dashboard</main>"),
11352                    ],
11353                    contains_boundary: false,
11354                },
11355            ),
11356            (
11357                "detail-page".to_string(),
11358                FragmentList {
11359                    fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
11360                    contains_boundary: false,
11361                },
11362            ),
11363            (
11364                "shared-card".to_string(),
11365                FragmentList {
11366                    fragments: vec![WebUIFragment::raw("<p>Shared</p>")],
11367                    contains_boundary: false,
11368                },
11369            ),
11370        ]);
11371        let mut protocol = WebUIProtocol::new(fragments);
11372        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11373        for tag in ["app-shell", "dashboard-page", "detail-page", "shared-card"] {
11374            let component = protocol.components.entry(tag.to_string()).or_default();
11375            component.css = format!(".{tag}{{display:block}}");
11376            component.template_json = r#"{"h":""}"#.to_string();
11377        }
11378        protocol
11379            .components
11380            .get_mut("app-shell")
11381            .expect("app shell component")
11382            .uses_shadow_dom = true;
11383        protocol.populate_style_closures(&["index.html"]);
11384
11385        let mut writer = TestWriter::new();
11386        handle(
11387            &protocol,
11388            &test_json!({}),
11389            &RenderOptions::new("index.html", "/"),
11390            &mut writer,
11391        )
11392        .unwrap();
11393        let html = writer.get_content();
11394
11395        for resource in ["app-shell", "shared-card", "dashboard-page"] {
11396            assert_eq!(
11397                html.matches(&format!(r#"data-webui-resource="{resource}""#))
11398                    .count(),
11399                1,
11400                "{resource} must install once in the active ShadowRoot: {html}"
11401            );
11402        }
11403        assert!(
11404            !html.contains(r#"data-webui-resource="detail-page""#),
11405            "inactive route CSS must not enter the ShadowRoot: {html}"
11406        );
11407        let shadow_start = html
11408            .find("<template shadowrootmode=\"open\">")
11409            .expect("Shadow template");
11410        let dashboard_style = html
11411            .find(r#"data-webui-resource="dashboard-page""#)
11412            .expect("dashboard style");
11413        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
11414        let shadow_end = html.find("</template>").expect("Shadow template close");
11415        assert!(
11416            shadow_start < dashboard_style
11417                && dashboard_style < dashboard_host
11418                && dashboard_host < shadow_end,
11419            "active route CSS must precede its generated host inside the owning ShadowRoot: {html}"
11420        );
11421
11422        let protocol = Protocol::new(protocol);
11423        let mut streamed = FlushTestWriter::default();
11424        WebUIHandler::new()
11425            .render_streaming(
11426                &protocol,
11427                &test_json!({}),
11428                &RenderOptions::new("index.html", "/"),
11429                &mut streamed,
11430            )
11431            .unwrap();
11432        for resource in ["app-shell", "shared-card"] {
11433            assert_eq!(
11434                streamed
11435                    .output
11436                    .matches(&format!(r#"data-webui-resource="{resource}""#))
11437                    .count(),
11438                1,
11439                "streaming must preserve tree-local delivery for {resource}: {}",
11440                streamed.output
11441            );
11442        }
11443        assert!(
11444            !streamed
11445                .output
11446                .contains(r#"data-webui-resource="dashboard-page""#),
11447            "route CSS is carried by the streamed checkpoint rather than installed before the route host: {}",
11448            streamed.output
11449        );
11450        assert!(
11451            streamed
11452                .output
11453                .contains(r#""dashboard-page":{"css":".dashboard-page{display:block}""#)
11454                && streamed
11455                    .output
11456                    .contains(r#""dashboard-page":["dashboard-page","shared-card"]"#),
11457            "streamed route CSS metadata must preserve tree ownership and cascade order: {}",
11458            streamed.output
11459        );
11460        assert!(
11461            !streamed
11462                .output
11463                .contains(r#"data-webui-resource="detail-page""#),
11464            "streaming must omit inactive route CSS: {}",
11465            streamed.output
11466        );
11467    }
11468
11469    #[test]
11470    fn document_styles_follow_doctype_before_headless_document_content() {
11471        for entry_fragments in [
11472            vec![
11473                WebUIFragment::raw("<!DOCTYPE html><html><body>"),
11474                structural_fragment("body_start"),
11475                WebUIFragment::raw("<my-card>"),
11476                WebUIFragment::component("my-card"),
11477                WebUIFragment::raw("</my-card></body></html>"),
11478            ],
11479            vec![
11480                WebUIFragment::raw("<!doctype html><html><my-card>"),
11481                WebUIFragment::component("my-card"),
11482                WebUIFragment::raw("</my-card></html>"),
11483            ],
11484        ] {
11485            let mut protocol = WebUIProtocol::new(HashMap::from([
11486                (
11487                    "index.html".to_string(),
11488                    FragmentList {
11489                        fragments: entry_fragments,
11490                        contains_boundary: false,
11491                    },
11492                ),
11493                (
11494                    "my-card".to_string(),
11495                    FragmentList {
11496                        fragments: vec![WebUIFragment::raw("<p>card</p>")],
11497                        contains_boundary: false,
11498                    },
11499                ),
11500            ]));
11501            protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11502            let component = protocol
11503                .components
11504                .entry("my-card".to_string())
11505                .or_default();
11506            component.css = ".card{color:red}".to_string();
11507            component.uses_shadow_dom = false;
11508            protocol.populate_style_closures(&["index.html"]);
11509
11510            let mut writer = TestWriter::new();
11511            handle(
11512                &protocol,
11513                &test_json!({}),
11514                &RenderOptions::new("index.html", "/"),
11515                &mut writer,
11516            )
11517            .expect("render");
11518            let html = writer.get_content();
11519
11520            assert!(
11521                html.to_ascii_lowercase().starts_with("<!doctype html>"),
11522                "the doctype must remain the first document token: {html}"
11523            );
11524            let style = html.find("data-webui-resource=\"my-card\"").expect("style");
11525            let document = html.find("<html").expect("document root");
11526            let component = html.find("<my-card>").expect("component host");
11527            assert!(
11528                style < document && style < component,
11529                "headless document styles must precede document content: {html}"
11530            );
11531        }
11532    }
11533
11534    #[test]
11535    fn authored_shadow_component_can_render_as_entry_fragment() {
11536        let mut protocol = WebUIProtocol::new(HashMap::from([(
11537            "my-card".to_string(),
11538            FragmentList {
11539                fragments: vec![
11540                    WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11541                    structural_fragment("shadow_styles:my-card"),
11542                    WebUIFragment::raw("<p>card</p></template>"),
11543                ],
11544                contains_boundary: false,
11545            },
11546        )]));
11547        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11548        let component = protocol
11549            .components
11550            .entry("my-card".to_string())
11551            .or_default();
11552        component.css = ".card{color:red}".to_string();
11553        component.uses_shadow_dom = true;
11554        protocol.populate_style_closures(&["my-card"]);
11555
11556        let mut writer = TestWriter::new();
11557        handle(
11558            &protocol,
11559            &test_json!({}),
11560            &RenderOptions::new("my-card", "/"),
11561            &mut writer,
11562        )
11563        .expect("render");
11564        let html = writer.get_content();
11565
11566        assert!(html.starts_with("<template shadowrootmode=\"open\"><style "));
11567        assert_eq!(html.matches("data-webui-resource=\"my-card\"").count(), 1);
11568        assert!(html.ends_with("<p>card</p></template>"));
11569    }
11570
11571    #[test]
11572    fn component_style_closures_escape_mixed_case_style_end_tags() {
11573        for strategy in [
11574            webui_protocol::CssStrategy::Style,
11575            webui_protocol::CssStrategy::Module,
11576        ] {
11577            let mut protocol = WebUIProtocol::new(HashMap::from([
11578                (
11579                    "index.html".to_string(),
11580                    FragmentList {
11581                        fragments: vec![
11582                            WebUIFragment::raw("<html><head>"),
11583                            structural_fragment("head_end"),
11584                            WebUIFragment::raw("</head><body><safe-card>"),
11585                            WebUIFragment::component("safe-card"),
11586                            WebUIFragment::raw("</safe-card></body></html>"),
11587                        ],
11588                        contains_boundary: false,
11589                    },
11590                ),
11591                (
11592                    "safe-card".to_string(),
11593                    FragmentList {
11594                        fragments: vec![WebUIFragment::raw("<p>Safe</p>")],
11595                        contains_boundary: false,
11596                    },
11597                ),
11598            ]));
11599            protocol.set_css_strategy(strategy);
11600            protocol.components.insert(
11601                "safe-card".to_string(),
11602                webui_protocol::ComponentData {
11603                    css: ".safe{content:'</StYlE>'}".to_string(),
11604                    ..Default::default()
11605                },
11606            );
11607            protocol.populate_style_closures(&["index.html"]);
11608            let mut writer = TestWriter::new();
11609
11610            handle(
11611                &protocol,
11612                &test_json!({}),
11613                &RenderOptions::new("index.html", "/"),
11614                &mut writer,
11615            )
11616            .unwrap();
11617
11618            let html = writer.get_content();
11619            assert!(html.contains(".safe{content:'<\\/StYlE>'}"), "{html}");
11620            assert!(!html.contains(".safe{content:'</StYlE>'}"), "{html}");
11621        }
11622    }
11623
11624    #[test]
11625    fn shadow_closure_supports_link_and_module_ssr_paths() {
11626        for strategy in [
11627            webui_protocol::CssStrategy::Link,
11628            webui_protocol::CssStrategy::Module,
11629        ] {
11630            let mut protocol = WebUIProtocol::new(HashMap::from([
11631                (
11632                    "index.html".to_string(),
11633                    FragmentList {
11634                        fragments: vec![
11635                            WebUIFragment::raw("<html><head>".to_string()),
11636                            structural_fragment("head_end"),
11637                            WebUIFragment::raw("</head><body><my-card>".to_string()),
11638                            WebUIFragment::component("my-card"),
11639                            WebUIFragment::raw("</my-card></body></html>".to_string()),
11640                        ],
11641                        contains_boundary: false,
11642                    },
11643                ),
11644                (
11645                    "my-card".to_string(),
11646                    FragmentList {
11647                        fragments: vec![
11648                            WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
11649                            structural_fragment("shadow_styles:my-card"),
11650                            WebUIFragment::raw("<p>card</p></template>".to_string()),
11651                        ],
11652                        contains_boundary: false,
11653                    },
11654                ),
11655            ]));
11656            protocol.set_css_strategy(strategy);
11657            let component = protocol
11658                .components
11659                .entry("my-card".to_string())
11660                .or_default();
11661            component.css = ".card{color:red}".to_string();
11662            component.css_href = "/my-card.css".to_string();
11663            component.uses_shadow_dom = true;
11664            protocol.populate_style_closures(&["index.html"]);
11665
11666            let mut writer = TestWriter::new();
11667            handle(
11668                &protocol,
11669                &test_json!({}),
11670                &RenderOptions::new("index.html", "/"),
11671                &mut writer,
11672            )
11673            .unwrap();
11674            let html = writer.get_content();
11675            match strategy {
11676                webui_protocol::CssStrategy::Link => {
11677                    let escaped_href = crate::html_encode::encode_safe("/my-card.css");
11678                    assert!(html.contains(&format!(
11679                        "<template shadowrootmode=\"open\"><link rel=\"stylesheet\" href=\"{escaped_href}\" data-webui-resource=\"my-card\" data-webui-strategy=\"link\">"
11680                    )));
11681                }
11682                webui_protocol::CssStrategy::Module => {
11683                    assert!(html.contains(
11684                        "<template shadowrootmode=\"open\"><style data-webui-resource=\"my-card\" data-webui-strategy=\"module\">.card{color:red}</style>"
11685                    ));
11686                    assert_eq!(html.matches("<script type=\"importmap\"").count(), 1);
11687                }
11688                webui_protocol::CssStrategy::Style => unreachable!(),
11689            }
11690        }
11691    }
11692
11693    #[test]
11694    fn test_module_preloads_emit_in_head_in_compiler_order() {
11695        // The whole value of these hints is ordering: preloads are issued in
11696        // document order over one connection, so the largest chunk must go
11697        // first. The compiler sorts; the handler must not reorder or dedupe.
11698        let mut fragments = HashMap::new();
11699        fragments.insert(
11700            "index.html".to_string(),
11701            FragmentList {
11702                fragments: vec![
11703                    WebUIFragment::raw(
11704                        r#"<html><head><script type="module" async src="/index.js"></script>"#
11705                            .to_string(),
11706                    ),
11707                    structural_fragment("head_end"),
11708                    WebUIFragment::raw("</head><body>".to_string()),
11709                    structural_fragment("body_end"),
11710                    WebUIFragment::raw("</body></html>".to_string()),
11711                ],
11712                contains_boundary: false,
11713            },
11714        );
11715
11716        let mut protocol = WebUIProtocol::new(fragments);
11717        protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
11718
11719        let state = test_json!({});
11720        let mut writer = TestWriter::new();
11721        handle(
11722            &protocol,
11723            &state,
11724            &RenderOptions::new("index.html", "/"),
11725            &mut writer,
11726        )
11727        .unwrap();
11728
11729        let html = writer.get_content();
11730        let head_end = html.find("</head>").expect("</head> missing");
11731        let head = &html[..head_end];
11732        assert!(
11733            head.contains(
11734                r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
11735            ),
11736            "hints must appear in <head> in the compiler's order: {html}"
11737        );
11738    }
11739
11740    #[test]
11741    fn test_no_module_preloads_emits_nothing() {
11742        let mut fragments = HashMap::new();
11743        fragments.insert(
11744            "index.html".to_string(),
11745            FragmentList {
11746                fragments: vec![
11747                    WebUIFragment::raw("<html><head>".to_string()),
11748                    structural_fragment("head_end"),
11749                    WebUIFragment::raw("</head><body>".to_string()),
11750                    structural_fragment("body_end"),
11751                    WebUIFragment::raw("</body></html>".to_string()),
11752                ],
11753                contains_boundary: false,
11754            },
11755        );
11756        let protocol = WebUIProtocol::new(fragments);
11757
11758        let state = test_json!({});
11759        let mut writer = TestWriter::new();
11760        handle(
11761            &protocol,
11762            &state,
11763            &RenderOptions::new("index.html", "/"),
11764            &mut writer,
11765        )
11766        .unwrap();
11767
11768        assert!(
11769            !writer.get_content().contains("modulepreload"),
11770            "a build without hints must be byte-identical to before"
11771        );
11772    }
11773
11774    #[test]
11775    fn test_link_strategy_head_links_follow_document_order() {
11776        // Regression for #381: Link-strategy <head> CSS <link> tags must be
11777        // emitted in document/traversal order, not alphabetical tag order.
11778        // Document order here is <z-widget> then <a-widget>; an alphabetical
11779        // sort would (incorrectly) place a-widget first.
11780        let mut fragments = HashMap::new();
11781        fragments.insert(
11782            "index.html".to_string(),
11783            FragmentList {
11784                fragments: vec![
11785                    WebUIFragment::raw("<html><head>".to_string()),
11786                    structural_fragment("head_end"),
11787                    WebUIFragment::raw("</head><body><z-widget>".to_string()),
11788                    WebUIFragment::component("z-widget"),
11789                    WebUIFragment::raw("</z-widget><a-widget>".to_string()),
11790                    WebUIFragment::component("a-widget"),
11791                    WebUIFragment::raw("</a-widget>".to_string()),
11792                    structural_fragment("body_end"),
11793                    WebUIFragment::raw("</body></html>".to_string()),
11794                ],
11795                contains_boundary: false,
11796            },
11797        );
11798        fragments.insert(
11799            "z-widget".to_string(),
11800            FragmentList {
11801                fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
11802                contains_boundary: false,
11803            },
11804        );
11805        fragments.insert(
11806            "a-widget".to_string(),
11807            FragmentList {
11808                fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
11809                contains_boundary: false,
11810            },
11811        );
11812
11813        let mut protocol = WebUIProtocol::new(fragments);
11814        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11815
11816        let z = protocol
11817            .components
11818            .entry("z-widget".to_string())
11819            .or_default();
11820        z.css_href = "z-widget.css".to_string();
11821        z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
11822
11823        let a = protocol
11824            .components
11825            .entry("a-widget".to_string())
11826            .or_default();
11827        a.css_href = "a-widget.css".to_string();
11828        a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
11829        protocol.populate_style_closures(&["index.html"]);
11830
11831        let state = test_json!({});
11832        let mut writer = TestWriter::new();
11833
11834        handle(
11835            &protocol,
11836            &state,
11837            &RenderOptions::new("index.html", "/"),
11838            &mut writer,
11839        )
11840        .unwrap();
11841
11842        let html = writer.get_content();
11843        let head_end = html.find("</head>").expect("</head> missing");
11844        let head_section = &html[..head_end];
11845
11846        let z_pos = head_section
11847            .find(r#"<link rel="stylesheet" href="z-widget.css""#)
11848            .expect("z-widget stylesheet link missing from <head>");
11849        let a_pos = head_section
11850            .find(r#"<link rel="stylesheet" href="a-widget.css""#)
11851            .expect("a-widget stylesheet link missing from <head>");
11852
11853        assert!(
11854            z_pos < a_pos,
11855            "CSS <link> tags must follow document order (z-widget before \
11856             a-widget), not alphabetical order: {html}"
11857        );
11858    }
11859
11860    #[test]
11861    fn test_css_module_emitted_in_component_light_dom() {
11862        // CSS module <style> tags are emitted inline in the component's light DOM,
11863        // not in <head>. This keeps SSR output lean — only rendered components
11864        // get their style definitions.
11865        let mut fragments = HashMap::new();
11866        fragments.insert(
11867            "index.html".to_string(),
11868            FragmentList {
11869                fragments: vec![
11870                    WebUIFragment::raw("<html><head>".to_string()),
11871                    structural_fragment("head_end"),
11872                    WebUIFragment::raw("</head><body><my-card>".to_string()),
11873                    WebUIFragment::component("my-card"),
11874                    WebUIFragment::raw("</my-card>".to_string()),
11875                    WebUIFragment::raw("</body></html>".to_string()),
11876                ],
11877                contains_boundary: false,
11878            },
11879        );
11880        fragments.insert(
11881            "my-card".to_string(),
11882            FragmentList {
11883                fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
11884                contains_boundary: false,
11885            },
11886        );
11887
11888        let mut protocol = WebUIProtocol::new(fragments);
11889        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11890        protocol
11891            .components
11892            .entry("my-card".to_string())
11893            .or_default()
11894            .css = "p{color:red}".to_string();
11895        protocol.populate_style_closures(&["index.html"]);
11896        let state = test_json!({});
11897        let mut writer = TestWriter::new();
11898
11899        handle(
11900            &protocol,
11901            &state,
11902            &RenderOptions::new("index.html", "/"),
11903            &mut writer,
11904        )
11905        .unwrap();
11906
11907        let html = writer.get_content();
11908
11909        // CSS module importmap must be INSIDE the component tag (light DOM)
11910        let tag_open = html.find("<my-card>").expect("<my-card> missing");
11911        let css_pos = html
11912            .find(r#"<script type="importmap""#)
11913            .expect("CSS module importmap missing");
11914        let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
11915        assert!(
11916            css_pos > tag_open && css_pos < tag_close,
11917            "CSS module should be inside component light DOM: {html}"
11918        );
11919
11920        // <head> should NOT contain module styles
11921        let head_end = html.find("</head>").expect("</head> missing");
11922        assert!(
11923            css_pos > head_end,
11924            "CSS module should not be in <head>: {html}"
11925        );
11926    }
11927
11928    #[test]
11929    fn styled_protocol_without_closures_is_rejected_for_full_ssr() {
11930        let mut protocol = WebUIProtocol::default();
11931        protocol.fragments = HashMap::from([
11932            (
11933                "index.html".to_string(),
11934                FragmentList {
11935                    fragments: vec![
11936                        WebUIFragment::raw("<legacy-card>"),
11937                        WebUIFragment::component("legacy-card"),
11938                        WebUIFragment::raw("</legacy-card>"),
11939                    ],
11940                    contains_boundary: false,
11941                },
11942            ),
11943            (
11944                "legacy-card".to_string(),
11945                FragmentList {
11946                    fragments: vec![WebUIFragment::raw("<p>Legacy</p>")],
11947                    contains_boundary: false,
11948                },
11949            ),
11950        ]);
11951        protocol.components.insert(
11952            "legacy-card".to_string(),
11953            webui_protocol::ComponentData {
11954                css: ".legacy{color:red}".to_string(),
11955                ..Default::default()
11956            },
11957        );
11958        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11959        let mut writer = TestWriter::new();
11960
11961        let error = handle(
11962            &protocol,
11963            &test_json!({}),
11964            &RenderOptions::new("index.html", "/"),
11965            &mut writer,
11966        )
11967        .expect_err("styled protocols require closure metadata");
11968        assert!(error
11969            .to_string()
11970            .contains("component style closure metadata is required"));
11971    }
11972
11973    #[test]
11974    fn test_css_module_emitted_for_route_components() {
11975        // Route components get CSS modules emitted inline in their light DOM.
11976        let template = r#"<h1>Dashboard</h1>"#;
11977
11978        let mut fragments = HashMap::new();
11979        fragments.insert(
11980            "index.html".to_string(),
11981            FragmentList {
11982                fragments: vec![
11983                    WebUIFragment::raw("<html><head>".to_string()),
11984                    structural_fragment("head_end"),
11985                    WebUIFragment::raw("</head><body>".to_string()),
11986                    WebUIFragment::route("/", "dash-page"),
11987                    structural_fragment("body_end"),
11988                    WebUIFragment::raw("</body></html>".to_string()),
11989                ],
11990                contains_boundary: false,
11991            },
11992        );
11993        fragments.insert(
11994            "dash-page".to_string(),
11995            FragmentList {
11996                fragments: vec![WebUIFragment::raw(template.to_string())],
11997                contains_boundary: false,
11998            },
11999        );
12000
12001        let mut protocol = WebUIProtocol::new(fragments);
12002        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12003        let comp = protocol
12004            .components
12005            .entry("dash-page".to_string())
12006            .or_default();
12007        comp.css = "h1{font-size:2rem}".to_string();
12008        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
12009        protocol.populate_style_closures(&["index.html"]);
12010        let state = test_json!({});
12011        let mut writer = TestWriter::new();
12012
12013        handle(
12014            &protocol,
12015            &state,
12016            &RenderOptions::new("index.html", "/"),
12017            &mut writer,
12018        )
12019        .unwrap();
12020
12021        let html = writer.get_content();
12022
12023        assert!(
12024            html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
12025            "Route component should have CSS module importmap with data: URI: {html}"
12026        );
12027        assert!(
12028            html.contains("<h1>Dashboard</h1>"),
12029            "Route component should render content: {html}"
12030        );
12031        let fallback = html
12032            .find(r#"data-webui-resource="dash-page" data-webui-strategy="module""#)
12033            .expect("route Module fallback");
12034        let host_open = html.find("<dash-page>").expect("route host");
12035        let content = html.find("<h1>Dashboard</h1>").expect("route content");
12036        assert!(
12037            fallback < host_open && host_open < content,
12038            "the Module fallback must precede the generated Light route host: {html}"
12039        );
12040        // The importmap belongs to the component's own Light DOM, never to the
12041        // route element. The router only treats `<link>`/`<style>` markers as
12042        // route-owned styles, so an importmap emitted as a direct route child
12043        // would be cleared on navigation and mistaken for the mounted
12044        // component. Keep it inside the host so that can never happen.
12045        let importmap = html
12046            .find(r#"<script type="importmap""#)
12047            .expect("route CSS module importmap");
12048        assert!(
12049            host_open < importmap,
12050            "the CSS module importmap must live inside the route host, not as a route child: {html}"
12051        );
12052    }
12053
12054    #[test]
12055    fn test_head_css_link_skipped_for_components_without_css() {
12056        // Regression: components without CSS files must not get <link> tags
12057        // in <head>, otherwise the browser requests a 404.
12058        let mut fragments = HashMap::new();
12059        fragments.insert(
12060            "index.html".to_string(),
12061            FragmentList {
12062                fragments: vec![
12063                    WebUIFragment::raw("<html><head>".to_string()),
12064                    structural_fragment("head_end"),
12065                    WebUIFragment::raw("</head><body>".to_string()),
12066                    WebUIFragment::component("has-css"),
12067                    WebUIFragment::component("no-css"),
12068                    structural_fragment("body_end"),
12069                    WebUIFragment::raw("</body></html>".to_string()),
12070                ],
12071                contains_boundary: false,
12072            },
12073        );
12074        fragments.insert(
12075            "has-css".to_string(),
12076            FragmentList {
12077                fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
12078                contains_boundary: false,
12079            },
12080        );
12081        fragments.insert(
12082            "no-css".to_string(),
12083            FragmentList {
12084                fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
12085                contains_boundary: false,
12086            },
12087        );
12088
12089        let mut protocol = WebUIProtocol::new(fragments);
12090        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
12091
12092        // Only has-css has an external stylesheet (Link strategy)
12093        protocol
12094            .components
12095            .entry("has-css".to_string())
12096            .or_default()
12097            .css_href = "has-css.css".to_string();
12098        protocol
12099            .components
12100            .entry("no-css".to_string())
12101            .or_default()
12102            .template_json = r#"{"h":"<p>plain</p>"}"#.to_string();
12103        protocol.populate_style_closures(&["index.html"]);
12104
12105        let state = test_json!({});
12106        let mut writer = TestWriter::new();
12107
12108        let handler = WebUIHandler::with_plugin(|| {
12109            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12110        });
12111        handler
12112            .handle(
12113                &protocol,
12114                &state,
12115                &RenderOptions::new("index.html", "/"),
12116                &mut writer,
12117            )
12118            .unwrap();
12119
12120        let html = writer.get_content();
12121        assert!(
12122            html.contains(r#"<link rel="stylesheet" href="has-css.css""#),
12123            "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
12124        );
12125        assert!(
12126            !html.contains("no-css.css"),
12127            "Component without CSS must NOT get a <link> in <head>: {html}"
12128        );
12129    }
12130
12131    #[test]
12132    fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
12133        // Simulates a page where app-shell renders cart-panel, but cart-panel
12134        // contains an <if> block with product-card inside. When the condition
12135        // is false (empty cart), product-card is NOT rendered — but it IS
12136        // reachable from the fragment graph. Its template metadata and CSS module
12137        // definition must be in the output so the client can mount it when
12138        // the <if> flips true. However, its bit must NOT be set in the
12139        // inventory — the inventory tracks what was actually rendered.
12140        let mut fragments = HashMap::new();
12141        fragments.insert(
12142            "index.html".to_string(),
12143            FragmentList {
12144                fragments: vec![
12145                    WebUIFragment::raw("<html><head>".to_string()),
12146                    structural_fragment("head_end"),
12147                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
12148                    WebUIFragment::component("app-shell"),
12149                    WebUIFragment::raw("</app-shell>".to_string()),
12150                    structural_fragment("body_end"),
12151                    WebUIFragment::raw("</body></html>".to_string()),
12152                ],
12153                contains_boundary: false,
12154            },
12155        );
12156        // app-shell contains a cart panel
12157        fragments.insert(
12158            "app-shell".to_string(),
12159            FragmentList {
12160                fragments: vec![
12161                    WebUIFragment::raw("<div>Shell</div>".to_string()),
12162                    WebUIFragment::component("cart-panel"),
12163                ],
12164                contains_boundary: false,
12165            },
12166        );
12167        // cart-panel has an <if> block containing product-card
12168        fragments.insert(
12169            "cart-panel".to_string(),
12170            FragmentList {
12171                fragments: vec![
12172                    WebUIFragment::raw("<aside>".to_string()),
12173                    WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
12174                    WebUIFragment::raw("</aside>".to_string()),
12175                ],
12176                contains_boundary: false,
12177            },
12178        );
12179        // cart-items (if block body) contains product-card
12180        fragments.insert(
12181            "cart-items".to_string(),
12182            FragmentList {
12183                fragments: vec![WebUIFragment::component("product-card")],
12184                contains_boundary: false,
12185            },
12186        );
12187        fragments.insert(
12188            "product-card".to_string(),
12189            FragmentList {
12190                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
12191                contains_boundary: false,
12192            },
12193        );
12194
12195        let mut protocol = WebUIProtocol::new(fragments);
12196        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12197        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12198        for name in ["app-shell", "cart-panel", "product-card"] {
12199            let comp = protocol.components.entry(name.to_string()).or_default();
12200            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
12201            comp.css = format!(".{name}{{display:block}}");
12202            if name == "cart-panel" {
12203                comp.hydration_mode = StateProjectionMode::Keys as i32;
12204                comp.hydration_keys = vec!["hasItems".to_string()];
12205            }
12206            if name == "product-card" {
12207                comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
12208            }
12209        }
12210        protocol.populate_style_closures(&["index.html"]);
12211
12212        // Render with hasItems=false — product-card should NOT be rendered
12213        let state = test_json!({ "hasItems": false });
12214        let mut writer = TestWriter::new();
12215
12216        let handler = WebUIHandler::with_plugin(|| {
12217            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12218        });
12219        handler
12220            .handle(
12221                &protocol,
12222                &state,
12223                &RenderOptions::new("index.html", "/"),
12224                &mut writer,
12225            )
12226            .unwrap();
12227
12228        let html = writer.get_content();
12229
12230        assert!(
12231            html.contains(r#"<script type="application/json" id="webui-data">"#),
12232            "non-executable SSR metadata should be emitted in the webui-data block: {html}"
12233        );
12234        assert!(
12235            html.contains(r#""state":{"hasItems":false}"#),
12236            "SSR state should live in the JSON data block: {html}"
12237        );
12238        assert!(
12239            html.contains(r#""inventory":"#),
12240            "SSR inventory should live in the JSON data block: {html}"
12241        );
12242        assert!(
12243            !html.contains("window.__webui={\""),
12244            "executable bootstrap must not embed the window.__webui JSON literal: {html}"
12245        );
12246        assert!(
12247            !html.contains(r#"document.getElementById("webui-data")"#),
12248            "SSR must not parse webui-data; client packages own that lazy load: {html}"
12249        );
12250        assert!(
12251            !html.contains("window.__webui=w;"),
12252            "executable bootstrap must not replace existing window.__webui registrations: {html}"
12253        );
12254        assert!(
12255            !html.contains("w.templateFns={\""),
12256            "template function emission must not replace existing templateFns registrations: {html}"
12257        );
12258        assert!(
12259            html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
12260            "template functions should merge into the flat templateFns registry: {html}"
12261        );
12262
12263        // product-card template IS in the output — it's a known component
12264        // whose template must be available for client-side <if> activation.
12265        assert!(
12266            html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
12267            "product-card template should be emitted even when unrendered: {html}"
12268        );
12269
12270        // product-card CSS module IS in the output — reachable components need
12271        // their stylesheet definitions for client-side <if> activation.
12272        assert!(
12273            html.contains(r#""product-card":"data:text/css,"#),
12274            "reachable product-card CSS module importmap should be emitted: {html}"
12275        );
12276
12277        // app-shell and cart-panel SHOULD be in the output (they were rendered)
12278        assert!(
12279            html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
12280            "rendered app-shell template should be emitted: {html}"
12281        );
12282        assert!(
12283            html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
12284            "rendered cart-panel template should be emitted: {html}"
12285        );
12286    }
12287
12288    #[test]
12289    fn component_asset_manifest_falls_back_to_body_start() {
12290        let fragments = HashMap::from([(
12291            "index.html".to_string(),
12292            FragmentList {
12293                fragments: vec![
12294                    WebUIFragment::raw("<body>".to_string()),
12295                    structural_fragment("body_start"),
12296                    WebUIFragment::raw("<main>Body-only host</main></body>".to_string()),
12297                ],
12298                contains_boundary: false,
12299            },
12300        )]);
12301        let mut protocol = WebUIProtocol::new(fragments);
12302        protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
12303            root: "lazy-panel".to_string(),
12304            style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
12305        }];
12306        let mut writer = TestWriter::new();
12307
12308        handle(
12309            &protocol,
12310            &Value::Null,
12311            &RenderOptions::new("index.html", "/"),
12312            &mut writer,
12313        )
12314        .unwrap();
12315
12316        assert_eq!(
12317            writer.get_content(),
12318            r#"<body><script type="application/json" id="webui-component-assets">{"lazy-panel":["/assets/lazy-panel.css"]}</script><main>Body-only host</main></body>"#
12319        );
12320    }
12321
12322    #[test]
12323    fn component_asset_styles_emit_once_when_body_precedes_head() {
12324        let fragments = HashMap::from([(
12325            "index.html".to_string(),
12326            FragmentList {
12327                fragments: vec![
12328                    WebUIFragment::raw("<body>".to_string()),
12329                    structural_fragment("body_start"),
12330                    WebUIFragment::raw("</body><head>".to_string()),
12331                    structural_fragment("head_end"),
12332                    WebUIFragment::raw("</head>".to_string()),
12333                ],
12334                contains_boundary: false,
12335            },
12336        )]);
12337        let mut protocol = WebUIProtocol::new(fragments);
12338        protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
12339            root: "lazy-panel".to_string(),
12340            style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
12341        }];
12342        let mut writer = TestWriter::new();
12343
12344        handle(
12345            &protocol,
12346            &Value::Null,
12347            &RenderOptions::new("index.html", "/"),
12348            &mut writer,
12349        )
12350        .unwrap();
12351
12352        assert_eq!(
12353            writer
12354                .get_content()
12355                .matches(r#"id="webui-component-assets""#)
12356                .count(),
12357            1
12358        );
12359    }
12360
12361    #[test]
12362    fn light_component_asset_styles_apply_at_head_end() {
12363        let fragments = HashMap::from([(
12364            "index.html".to_string(),
12365            FragmentList {
12366                fragments: vec![
12367                    WebUIFragment::raw("<html><head>".to_string()),
12368                    structural_fragment("head_end"),
12369                    WebUIFragment::raw("</head><body></body></html>".to_string()),
12370                ],
12371                contains_boundary: false,
12372            },
12373        )]);
12374        let mut protocol = WebUIProtocol::new(fragments);
12375        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
12376        for root in ["lazy-panel", "secondary-panel"] {
12377            protocol.components.insert(
12378                root.to_string(),
12379                webui_protocol::ComponentData {
12380                    uses_shadow_dom: false,
12381                    ..Default::default()
12382                },
12383            );
12384        }
12385        protocol.component_asset_style_preloads = vec![
12386            ComponentAssetStylePreload {
12387                root: "lazy-panel".to_string(),
12388                style_hrefs: vec![
12389                    "/assets/lazy-panel.css".to_string(),
12390                    "/assets/shared.css".to_string(),
12391                ],
12392            },
12393            ComponentAssetStylePreload {
12394                root: "secondary-panel".to_string(),
12395                style_hrefs: vec![
12396                    "/assets/secondary-panel.css".to_string(),
12397                    "/assets/shared.css".to_string(),
12398                ],
12399            },
12400        ];
12401        let mut writer = TestWriter::new();
12402
12403        handle(
12404            &protocol,
12405            &Value::Null,
12406            &RenderOptions::new("index.html", "/"),
12407            &mut writer,
12408        )
12409        .unwrap();
12410
12411        let html = writer.get_content();
12412        assert_eq!(html.matches(r#"rel="stylesheet""#).count(), 3);
12413        assert_eq!(html.matches("/assets/shared.css").count(), 1);
12414        assert!(!html.contains(COMPONENT_ASSET_MANIFEST_ID));
12415        let links_end = html
12416            .find(r#"<link rel="stylesheet" href="/assets/secondary-panel.css">"#)
12417            .expect("asset stylesheet must be emitted");
12418        let head_end = html.find("</head>").expect("head must close");
12419        assert!(links_end < head_end);
12420    }
12421
12422    // ── CSP nonce on CSS module importmap ───────────────────────────
12423    //
12424    // When `RenderOptions::with_nonce(...)` is set, every inline
12425    // `<script type="importmap">` definition emitted during SSR for a
12426    // component CSS module must include `nonce="VALUE"` so strict CSP
12427    // `script-src 'nonce-...'` policies allow it. The without-nonce case
12428    // is already covered by other CSS module tests (e.g.
12429    // `test_css_module_emitted_for_route_components`).
12430
12431    #[test]
12432    fn test_css_module_emits_nonce_attribute_when_nonce_set() {
12433        // Per-component first-render path (`emit_css_module`).
12434        let template = r#"<h1>Dashboard</h1>"#;
12435
12436        let mut fragments = HashMap::new();
12437        fragments.insert(
12438            "index.html".to_string(),
12439            FragmentList {
12440                fragments: vec![
12441                    WebUIFragment::raw("<html><head>".to_string()),
12442                    structural_fragment("head_end"),
12443                    WebUIFragment::raw("</head><body>".to_string()),
12444                    WebUIFragment::route("/", "dash-page"),
12445                    structural_fragment("body_end"),
12446                    WebUIFragment::raw("</body></html>".to_string()),
12447                ],
12448                contains_boundary: false,
12449            },
12450        );
12451        fragments.insert(
12452            "dash-page".to_string(),
12453            FragmentList {
12454                fragments: vec![WebUIFragment::raw(template.to_string())],
12455                contains_boundary: false,
12456            },
12457        );
12458
12459        let mut protocol = WebUIProtocol::new(fragments);
12460        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12461        let comp = protocol
12462            .components
12463            .entry("dash-page".to_string())
12464            .or_default();
12465        comp.css = "h1{font-size:2rem}".to_string();
12466        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
12467        protocol.populate_style_closures(&["index.html"]);
12468        let state = test_json!({});
12469        let mut writer = TestWriter::new();
12470
12471        handle(
12472            &protocol,
12473            &state,
12474            &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
12475            &mut writer,
12476        )
12477        .unwrap();
12478
12479        let html = writer.get_content();
12480
12481        assert!(
12482            html.contains(
12483                r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="dash-page">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
12484            ),
12485            "CSS module importmap tag should include nonce attribute in canonical order: {html}"
12486        );
12487    }
12488
12489    #[test]
12490    fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
12491        // Body-end emission path for reachable-but-unrendered components
12492        // (the second site touched by the patch). Triggered via a false
12493        // `<if>` block under hydration; requires the WebUI plugin so the
12494        // body_end hook executes.
12495        let mut fragments = HashMap::new();
12496        fragments.insert(
12497            "index.html".to_string(),
12498            FragmentList {
12499                fragments: vec![
12500                    WebUIFragment::raw("<html><head>".to_string()),
12501                    structural_fragment("head_end"),
12502                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
12503                    WebUIFragment::component("app-shell"),
12504                    WebUIFragment::raw("</app-shell>".to_string()),
12505                    structural_fragment("body_end"),
12506                    WebUIFragment::raw("</body></html>".to_string()),
12507                ],
12508                contains_boundary: false,
12509            },
12510        );
12511        fragments.insert(
12512            "app-shell".to_string(),
12513            FragmentList {
12514                fragments: vec![WebUIFragment::if_cond(
12515                    ConditionExpr::identifier("hasItems"),
12516                    "cart-items",
12517                )],
12518                contains_boundary: false,
12519            },
12520        );
12521        fragments.insert(
12522            "cart-items".to_string(),
12523            FragmentList {
12524                fragments: vec![WebUIFragment::component("product-card")],
12525                contains_boundary: false,
12526            },
12527        );
12528        fragments.insert(
12529            "product-card".to_string(),
12530            FragmentList {
12531                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
12532                contains_boundary: false,
12533            },
12534        );
12535
12536        let mut protocol = WebUIProtocol::new(fragments);
12537        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12538        for name in ["app-shell", "product-card"] {
12539            let comp = protocol.components.entry(name.to_string()).or_default();
12540            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
12541            comp.css = format!(".{name}{{display:block}}");
12542        }
12543        protocol.populate_style_closures(&["index.html"]);
12544
12545        // Render with hasItems=false so product-card is reachable but not
12546        // rendered, forcing its CSS module emission through the body_end path.
12547        let state = test_json!({ "hasItems": false });
12548        let mut writer = TestWriter::new();
12549
12550        let handler = WebUIHandler::with_plugin(|| {
12551            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12552        });
12553        handler
12554            .handle(
12555                &protocol,
12556                &state,
12557                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
12558                &mut writer,
12559            )
12560            .unwrap();
12561
12562        let html = writer.get_content();
12563
12564        assert!(
12565            html.contains(
12566                r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="product-card">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
12567            ),
12568            "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
12569        );
12570    }
12571
12572    #[test]
12573    fn projected_state_excludes_non_hydration_keys() -> Result<()> {
12574        let mut fragments = HashMap::new();
12575        fragments.insert(
12576            "index.html".to_string(),
12577            FragmentList {
12578                fragments: vec![
12579                    WebUIFragment::raw("<html><body><style>".to_string()),
12580                    WebUIFragment::signal("tokens.light", true),
12581                    WebUIFragment::raw("</style>".to_string()),
12582                    structural_fragment("body_end"),
12583                    WebUIFragment::raw("</body></html>".to_string()),
12584                ],
12585                contains_boundary: false,
12586            },
12587        );
12588        fragments.insert(
12589            "app-shell".to_string(),
12590            FragmentList {
12591                fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
12592                contains_boundary: false,
12593            },
12594        );
12595        let index_fragments = fragments
12596            .get_mut("index.html")
12597            .expect("index fixture should exist");
12598        index_fragments
12599            .fragments
12600            .insert(1, WebUIFragment::component("app-shell"));
12601        let mut protocol = WebUIProtocol::new(fragments);
12602        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12603        // Only `name` is a hydration key. `tokens` is a server-only field
12604        // (used above to resolve SSR CSS variables) and is NOT in the component
12605        // hydration keys,
12606        // so projection MUST keep it out of the client state block.
12607        protocol.components.insert(
12608            "app-shell".to_string(),
12609            webui_protocol::ComponentData {
12610                hydration_mode: StateProjectionMode::Keys as i32,
12611                hydration_keys: vec!["name".to_string()],
12612                ..Default::default()
12613            },
12614        );
12615        let state = test_json!({
12616            "name": "Alice",
12617            "tokens": {
12618                "light": "--color-brand: red;"
12619            }
12620        });
12621        let mut writer = TestWriter::new();
12622        let handler = WebUIHandler::with_plugin(|| {
12623            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12624        });
12625        handler.handle(
12626            &protocol,
12627            &state,
12628            &RenderOptions::new("index.html", "/"),
12629            &mut writer,
12630        )?;
12631        let output = writer.get_content();
12632
12633        // SSR still reads `tokens` to resolve the inline <style>...
12634        assert!(output.contains("--color-brand: red;"));
12635        // ...but only the hydration key reaches the client state.
12636        assert!(output.contains(r#""name":"Alice""#));
12637        assert!(!output.contains(r#""tokens""#));
12638        Ok(())
12639    }
12640
12641    #[test]
12642    fn full_initial_strategy_preserves_complete_state() -> Result<()> {
12643        let mut fragments = HashMap::new();
12644        fragments.insert(
12645            "index.html".to_string(),
12646            FragmentList {
12647                fragments: vec![
12648                    WebUIFragment::raw("<html><body>"),
12649                    structural_fragment("body_end"),
12650                    WebUIFragment::raw("</body></html>"),
12651                ],
12652                contains_boundary: false,
12653            },
12654        );
12655        let protocol = WebUIProtocol::new(fragments);
12656        let state = test_json!({
12657            "client": "visible",
12658            "serverOnly": "also preserved",
12659        });
12660        let handler = WebUIHandler::with_plugin(|| {
12661            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12662        });
12663        let mut writer = TestWriter::new();
12664        handler.handle(
12665            &protocol,
12666            &state,
12667            &RenderOptions::new("index.html", "/"),
12668            &mut writer,
12669        )?;
12670        let output = writer.get_content();
12671        assert!(output.contains(r#""client":"visible""#));
12672        assert!(output.contains(r#""serverOnly":"also preserved""#));
12673        Ok(())
12674    }
12675
12676    #[test]
12677    fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
12678        let mut fragments = HashMap::new();
12679        fragments.insert(
12680            "index.html".to_string(),
12681            FragmentList {
12682                fragments: vec![
12683                    WebUIFragment::raw("<html><body>"),
12684                    WebUIFragment::component("app-shell"),
12685                    structural_fragment("body_end"),
12686                    WebUIFragment::raw("</body></html>"),
12687                ],
12688                contains_boundary: false,
12689            },
12690        );
12691        fragments.insert(
12692            "app-shell".to_string(),
12693            FragmentList {
12694                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12695                contains_boundary: false,
12696            },
12697        );
12698        let mut protocol = WebUIProtocol::new(fragments);
12699        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12700        protocol.components.insert(
12701            "app-shell".to_string(),
12702            webui_protocol::ComponentData {
12703                hydration_mode: StateProjectionMode::All as i32,
12704                ..Default::default()
12705            },
12706        );
12707        let state = test_json!({
12708            "known": "value",
12709            "possiblyInherited": "must not be dropped",
12710        });
12711        let handler = WebUIHandler::with_plugin(|| {
12712            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12713        });
12714        let mut writer = TestWriter::new();
12715        handler.handle(
12716            &protocol,
12717            &state,
12718            &RenderOptions::new("index.html", "/"),
12719            &mut writer,
12720        )?;
12721        let output = writer.get_content();
12722        assert!(output.contains(r#""known":"value""#));
12723        assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
12724        Ok(())
12725    }
12726
12727    #[test]
12728    fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
12729        let mut fragments = HashMap::new();
12730        fragments.insert(
12731            "index.html".to_string(),
12732            FragmentList {
12733                fragments: vec![
12734                    WebUIFragment::raw("<html><body>"),
12735                    WebUIFragment::component("app-shell"),
12736                    structural_fragment("body_end"),
12737                    WebUIFragment::raw("</body></html>"),
12738                ],
12739                contains_boundary: false,
12740            },
12741        );
12742        fragments.insert(
12743            "app-shell".to_string(),
12744            FragmentList {
12745                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12746                contains_boundary: false,
12747            },
12748        );
12749        let mut protocol = WebUIProtocol::new(fragments);
12750        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12751        let state = test_json!({
12752            "known": "value",
12753            "serverOnly": "must not be dropped",
12754        });
12755        let handler = WebUIHandler::with_plugin(|| {
12756            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12757        });
12758        let mut writer = TestWriter::new();
12759        handler.handle(
12760            &protocol,
12761            &state,
12762            &RenderOptions::new("index.html", "/"),
12763            &mut writer,
12764        )?;
12765        let output = writer.get_content();
12766        assert!(output.contains(r#""known":"value""#));
12767        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12768        Ok(())
12769    }
12770
12771    #[test]
12772    fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
12773        let mut fragments = HashMap::new();
12774        fragments.insert(
12775            "index.html".to_string(),
12776            FragmentList {
12777                fragments: vec![
12778                    WebUIFragment::raw("<html><body>"),
12779                    WebUIFragment::component("app-shell"),
12780                    structural_fragment("body_end"),
12781                    WebUIFragment::raw("</body></html>"),
12782                ],
12783                contains_boundary: false,
12784            },
12785        );
12786        fragments.insert(
12787            "app-shell".to_string(),
12788            FragmentList {
12789                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12790                contains_boundary: false,
12791            },
12792        );
12793        let mut protocol = WebUIProtocol::new(fragments);
12794        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12795        protocol.components.insert(
12796            "app-shell".to_string(),
12797            webui_protocol::ComponentData {
12798                hydration_mode: i32::MAX,
12799                ..Default::default()
12800            },
12801        );
12802        let state = test_json!({
12803            "known": "value",
12804            "serverOnly": "must not be dropped",
12805        });
12806        let handler = WebUIHandler::with_plugin(|| {
12807            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12808        });
12809        let mut writer = TestWriter::new();
12810        handler.handle(
12811            &protocol,
12812            &state,
12813            &RenderOptions::new("index.html", "/"),
12814            &mut writer,
12815        )?;
12816        let output = writer.get_content();
12817        assert!(output.contains(r#""known":"value""#));
12818        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12819        Ok(())
12820    }
12821
12822    #[test]
12823    fn legacy_navigation_keys_with_default_mode_remain_keyed() {
12824        let mut protocol = WebUIProtocol::new(HashMap::new());
12825        protocol.components.insert(
12826            "app-shell".to_string(),
12827            webui_protocol::ComponentData {
12828                navigation_keys: vec!["selected".to_string()],
12829                ..Default::default()
12830            },
12831        );
12832
12833        match collect_navigation_state(&protocol, ["app-shell"]) {
12834            StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
12835            StateSelection::Full
12836            | StateSelection::KeyIds(_)
12837            | StateSelection::FullExceptKeyIds(_) => {
12838                panic!("legacy navigation keys should remain owned and projected")
12839            }
12840        }
12841    }
12842
12843    #[test]
12844    fn legacy_navigation_without_projection_metadata_preserves_full_state() {
12845        let mut protocol = WebUIProtocol::new(HashMap::new());
12846        protocol.components.insert(
12847            "app-shell".to_string(),
12848            webui_protocol::ComponentData::default(),
12849        );
12850
12851        assert!(matches!(
12852            collect_navigation_state(&protocol, ["app-shell"]),
12853            StateSelection::Full
12854        ));
12855    }
12856
12857    #[test]
12858    fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
12859        let mut fragments = HashMap::new();
12860        fragments.insert(
12861            "index.html".to_string(),
12862            FragmentList {
12863                fragments: vec![
12864                    WebUIFragment::raw("<html><body>".to_string()),
12865                    structural_fragment("body_end"),
12866                    WebUIFragment::raw("</body></html>".to_string()),
12867                ],
12868                contains_boundary: false,
12869            },
12870        );
12871        let mut protocol = WebUIProtocol::new(fragments);
12872        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12873        let state = test_json!({
12874            "title": "Legacy state",
12875            "serverOnly": "preserved",
12876        });
12877        let handler = WebUIHandler::with_plugin(|| {
12878            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12879        });
12880
12881        let mut writer = TestWriter::new();
12882        handler.handle(
12883            &protocol,
12884            &state,
12885            &RenderOptions::new("index.html", "/"),
12886            &mut writer,
12887        )?;
12888        assert!(writer.get_content().contains(r#""state":{}"#));
12889        assert!(!writer.get_content().contains("Legacy state"));
12890        assert!(!writer.get_content().contains("preserved"));
12891        Ok(())
12892    }
12893
12894    #[test]
12895    fn scriptless_component_state_is_navigation_only() -> Result<()> {
12896        let mut fragments = HashMap::new();
12897        fragments.insert(
12898            "index.html".to_string(),
12899            FragmentList {
12900                fragments: vec![
12901                    WebUIFragment::raw("<html><body>"),
12902                    WebUIFragment::component("items-page"),
12903                    structural_fragment("body_end"),
12904                    WebUIFragment::raw("</body></html>"),
12905                ],
12906                contains_boundary: false,
12907            },
12908        );
12909        fragments.insert(
12910            "items-page".to_string(),
12911            FragmentList {
12912                fragments: vec![WebUIFragment::raw("<p>Items</p>")],
12913                contains_boundary: false,
12914            },
12915        );
12916        let mut protocol = WebUIProtocol::new(fragments);
12917        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12918        protocol.components.insert(
12919            "items-page".to_string(),
12920            webui_protocol::ComponentData {
12921                template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
12922                navigation_mode: Some(StateProjectionMode::Keys as i32),
12923                navigation_keys: vec!["items".into()],
12924                ..Default::default()
12925            },
12926        );
12927        let state = test_json!({
12928            "items": ["STATE_SENTINEL"],
12929            "serverOnly": "SECRET_SENTINEL",
12930        });
12931        let handler = WebUIHandler::with_plugin(|| {
12932            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12933        });
12934        let mut writer = TestWriter::new();
12935
12936        handler.handle(
12937            &protocol,
12938            &state,
12939            &RenderOptions::new("index.html", "/"),
12940            &mut writer,
12941        )?;
12942
12943        let output = writer.get_content();
12944        assert!(output.contains(r#""state":{}"#));
12945        assert!(!output.contains("STATE_SENTINEL"));
12946        assert!(!output.contains("SECRET_SENTINEL"));
12947        Ok(())
12948    }
12949
12950    #[test]
12951    fn write_selected_state_projects_and_escapes() {
12952        // `keep` is in the sorted key set and its value contains a `</` that
12953        // must be escaped; `drop` is absent and must be projected out.
12954        let state = test_json!({
12955            "drop": "secret",
12956            "keep": "</script><b>"
12957        });
12958        let keys = ["keep"];
12959        let selection = StateSelection::Keys(keys.to_vec());
12960        let mut sink = TestWriter::new();
12961        let mut scratch = Vec::new();
12962        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12963        assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
12964    }
12965
12966    #[test]
12967    fn write_selected_state_non_object_projection_emits_empty_object() {
12968        let state = test_json!("scalar state has nothing hydratable");
12969        let keys: [&str; 0] = [];
12970        let selection = StateSelection::Keys(keys.to_vec());
12971        let mut sink = TestWriter::new();
12972        let mut scratch = Vec::new();
12973        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12974        assert_eq!(sink.get_content(), "{}");
12975    }
12976
12977    #[test]
12978    fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
12979        let state = test_json!({
12980            "keptA": 1,
12981            "keptB": 2,
12982            "serverOnlyA": 3,
12983            "serverOnlyB": 4,
12984        });
12985        let keys = ["keptA", "keptA", "keptB", "missing"];
12986        let selection = StateSelection::Keys(keys.to_vec());
12987        let mut sink = TestWriter::new();
12988        let mut scratch = Vec::new();
12989        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12990        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12991    }
12992
12993    #[test]
12994    fn write_selected_state_map_first_matches_schema_first_output() {
12995        let state = test_json!({
12996            "keptA": 1,
12997            "keptB": 2,
12998        });
12999        let keys = ["keptA", "keptB", "missingA", "missingB"];
13000        let selection = StateSelection::Keys(keys.to_vec());
13001        let mut sink = TestWriter::new();
13002        let mut scratch = Vec::new();
13003        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
13004        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
13005    }
13006
13007    #[test]
13008    fn write_selected_state_full_preserves_and_escapes_state() {
13009        let state = test_json!({
13010            "serverOnly": "</script><b>",
13011            "value": 42,
13012        });
13013        let mut sink = TestWriter::new();
13014        let mut scratch = Vec::new();
13015        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13016        assert_eq!(
13017            sink.get_content(),
13018            r#"{"serverOnly":"<\/script><b>","value":42}"#
13019        );
13020    }
13021
13022    #[test]
13023    fn script_safe_json_str_escapes_only_end_tag_openers() -> Result<()> {
13024        let cases = [
13025            ("", ""),
13026            ("</", "<\\/"),
13027            ("</a</b", "<\\/a<\\/b"),
13028            ("<<//", "<<\\//"),
13029            ("a<b>c<", "a<b>c<"),
13030            ("x</", "x<\\/"),
13031            ("a<b>c</d>e</f", "a<b>c<\\/d>e<\\/f"),
13032            ("<img src=\"/a.png\"><br/>", "<img src=\"/a.png\"><br/>"),
13033        ];
13034        for (json, expected) in cases {
13035            let mut sink = TestWriter::new();
13036            write_script_safe_json_str(&mut sink, json)?;
13037            assert_eq!(sink.get_content(), expected, "input {json:?}");
13038        }
13039        Ok(())
13040    }
13041
13042    #[test]
13043    fn script_safe_json_str_finds_end_tag_openers_at_every_offset() -> Result<()> {
13044        // The `</` search switches between short-haystack and vectorized
13045        // paths, so move openers and `<`/`/` decoys across several blocks.
13046        let mut json = String::with_capacity(512);
13047        for gap in 0..130 {
13048            json.clear();
13049            json.push_str("{\"h\":\"<img src=\\\"/a.png\\\">");
13050            json.push_str(&"x".repeat(gap));
13051            json.push_str("</p><br/>");
13052            json.push_str(&"y".repeat(gap % 37));
13053            json.push_str("</");
13054            let mut sink = TestWriter::new();
13055            write_script_safe_json_str(&mut sink, &json)?;
13056            assert_eq!(sink.get_content(), json.replace("</", "<\\/"), "gap {gap}");
13057        }
13058        Ok(())
13059    }
13060
13061    #[test]
13062    fn component_asset_style_manifest_is_serialized_once_and_script_safe() {
13063        let preloads = vec![ComponentAssetStylePreload {
13064            root: "lazy-panel".to_string(),
13065            style_hrefs: vec!["/assets/</script><script>alert(1)</script>.css".to_string()],
13066        }];
13067
13068        let manifest = serialize_component_asset_style_manifest(&preloads).unwrap();
13069
13070        assert_eq!(
13071            manifest,
13072            r#"{"lazy-panel":["/assets/<\/script><script>alert(1)<\/script>.css"]}"#
13073        );
13074    }
13075
13076    #[test]
13077    fn json_scratch_capacity_reused_across_checkpoint_payloads() {
13078        // Simulates several checkpoint bootstraps serialized in one render: a
13079        // single request-local buffer must grow once for the largest payload and
13080        // then be reused for subsequent payloads without reallocating. This is
13081        // the deterministic seam behind the streaming path's allocation gate —
13082        // `write_script_safe_json` is `pub(crate)`, so no public API is exposed.
13083        let mut sink = TestWriter::new();
13084        let mut scratch: Vec<u8> = Vec::new();
13085        // No allocation until serialization actually needs it.
13086        assert_eq!(scratch.capacity(), 0);
13087
13088        // The largest payload first establishes the high-water capacity.
13089        let mut large = serde_json::Map::new();
13090        for index in 0..64 {
13091            large.insert(
13092                format!("component_{index:04}"),
13093                Value::String("x".repeat(48)),
13094            );
13095        }
13096        let large = Value::Object(large);
13097        write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
13098        let high_water = scratch.capacity();
13099        assert!(high_water > 0);
13100
13101        // Subsequent smaller checkpoint payloads reuse the same buffer: capacity
13102        // never grows (no per-checkpoint allocation) and never shrinks.
13103        for seq in 0..8 {
13104            let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
13105            write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
13106            assert_eq!(
13107                scratch.capacity(),
13108                high_water,
13109                "scratch reallocated on checkpoint {seq}"
13110            );
13111        }
13112    }
13113
13114    #[test]
13115    fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
13116        let mut fragments = HashMap::new();
13117        fragments.insert(
13118            "index.html".to_string(),
13119            FragmentList {
13120                fragments: vec![
13121                    WebUIFragment::raw("<html><body>"),
13122                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
13123                        path: "/".to_string(),
13124                        fragment_id: "home-page".to_string(),
13125                        exact: true,
13126                        ..Default::default()
13127                    }),
13128                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
13129                        path: "/admin".to_string(),
13130                        fragment_id: "admin-page".to_string(),
13131                        exact: true,
13132                        ..Default::default()
13133                    }),
13134                    structural_fragment("body_end"),
13135                    WebUIFragment::raw("</body></html>"),
13136                ],
13137
13138                contains_boundary: false,
13139            },
13140        );
13141        fragments.insert(
13142            "home-page".to_string(),
13143            FragmentList {
13144                fragments: vec![WebUIFragment::raw("<p>Home</p>")],
13145                contains_boundary: false,
13146            },
13147        );
13148        fragments.insert(
13149            "admin-page".to_string(),
13150            FragmentList {
13151                fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
13152                contains_boundary: false,
13153            },
13154        );
13155
13156        let mut protocol = WebUIProtocol::new(fragments);
13157        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13158        protocol.components.insert(
13159            "home-page".to_string(),
13160            webui_protocol::ComponentData {
13161                template_json: "{}".to_string(),
13162                hydration_mode: StateProjectionMode::Keys as i32,
13163                hydration_keys: vec!["homeTitle".to_string()],
13164                ..Default::default()
13165            },
13166        );
13167        protocol.components.insert(
13168            "admin-page".to_string(),
13169            webui_protocol::ComponentData {
13170                template_json: "{}".to_string(),
13171                hydration_mode: StateProjectionMode::Keys as i32,
13172                hydration_keys: vec!["adminToken".to_string()],
13173                ..Default::default()
13174            },
13175        );
13176        let state = test_json!({
13177            "homeTitle": "Welcome",
13178            "adminToken": "TOP_SECRET_SENTINEL",
13179        });
13180        let handler = WebUIHandler::with_plugin(|| {
13181            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13182        });
13183        let mut writer = TestWriter::new();
13184        handler.handle(
13185            &protocol,
13186            &state,
13187            &RenderOptions::new("index.html", "/"),
13188            &mut writer,
13189        )?;
13190        let output = writer.get_content();
13191        assert!(output.contains(r#""homeTitle":"Welcome""#));
13192        assert!(!output.contains("TOP_SECRET_SENTINEL"));
13193        assert!(!output.contains(r#""adminToken""#));
13194        Ok(())
13195    }
13196
13197    #[test]
13198    fn test_component_attr_name_aria() {
13199        // component_attr_name correctly maps ARIA attributes via the shared table
13200        assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
13201        assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
13202        assert_eq!(
13203            component_attr_name("aria-activedescendant"),
13204            "ariaActiveDescendant"
13205        );
13206        assert_eq!(component_attr_name("aria-label"), "ariaLabel");
13207        assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
13208    }
13209
13210    #[test]
13211    fn test_component_attr_name_html_global() {
13212        assert_eq!(component_attr_name("readonly"), "readOnly");
13213        assert_eq!(component_attr_name("tabindex"), "tabIndex");
13214        assert_eq!(component_attr_name("accesskey"), "accessKey");
13215        assert_eq!(component_attr_name("contenteditable"), "contentEditable");
13216        assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
13217        assert_eq!(component_attr_name("inputmode"), "inputMode");
13218        assert_eq!(component_attr_name("maxlength"), "maxLength");
13219        assert_eq!(component_attr_name("minlength"), "minLength");
13220        assert_eq!(component_attr_name("novalidate"), "noValidate");
13221        assert_eq!(component_attr_name("formaction"), "formAction");
13222        assert_eq!(component_attr_name("ismap"), "isMap");
13223        assert_eq!(component_attr_name("usemap"), "useMap");
13224    }
13225
13226    #[test]
13227    fn test_component_attr_name_strips_colon() {
13228        assert_eq!(component_attr_name(":readonly"), "readOnly");
13229        assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
13230        assert_eq!(component_attr_name(":data-title"), "dataTitle");
13231    }
13232
13233    #[test]
13234    fn test_component_attr_name_regular() {
13235        assert_eq!(component_attr_name("data-title"), "dataTitle");
13236        assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
13237        assert_eq!(component_attr_name("simple"), "simple");
13238    }
13239
13240    // ── allowed_query SSR emission tests ─────────────────────────────
13241
13242    fn make_query_route_protocol() -> WebUIProtocol {
13243        use webui_protocol::WebUiFragmentRoute;
13244
13245        let mut fragments = HashMap::new();
13246
13247        fragments.insert(
13248            "index.html".to_string(),
13249            FragmentList {
13250                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
13251                    path: "/".into(),
13252                    fragment_id: "app-shell".into(),
13253                    exact: false,
13254                    children: vec![
13255                        WebUiFragmentRoute {
13256                            path: "compose".into(),
13257                            fragment_id: "compose-page".into(),
13258                            exact: true,
13259                            allowed_query: "action,to,subject".into(),
13260                            keep_alive: false,
13261                            ..Default::default()
13262                        },
13263                        WebUiFragmentRoute {
13264                            path: "settings".into(),
13265                            fragment_id: "settings-page".into(),
13266                            exact: true,
13267                            keep_alive: false,
13268                            ..Default::default()
13269                        },
13270                    ],
13271                    keep_alive: false,
13272                    ..Default::default()
13273                })],
13274
13275                contains_boundary: false,
13276            },
13277        );
13278
13279        fragments.insert(
13280            "app-shell".to_string(),
13281            FragmentList {
13282                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
13283                contains_boundary: false,
13284            },
13285        );
13286        fragments.insert(
13287            "compose-page".to_string(),
13288            FragmentList {
13289                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
13290                contains_boundary: false,
13291            },
13292        );
13293        fragments.insert(
13294            "settings-page".to_string(),
13295            FragmentList {
13296                fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
13297                contains_boundary: false,
13298            },
13299        );
13300
13301        WebUIProtocol::new(fragments)
13302    }
13303
13304    #[test]
13305    fn test_matched_route_omits_query_attr_from_dom() {
13306        let protocol = make_query_route_protocol();
13307        let state = test_json!({});
13308        let handler = WebUIHandler::new();
13309        let mut writer = TestWriter::new();
13310
13311        handler
13312            .handle(
13313                &protocol,
13314                &state,
13315                &RenderOptions::new("index.html", "/compose"),
13316                &mut writer,
13317            )
13318            .expect("render failed");
13319
13320        let html = writer.get_content();
13321        // query attr is no longer in DOM — it's in the SSR chain JSON instead
13322        assert!(
13323            !html.contains(r#"query="action,to,subject""#),
13324            "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
13325        );
13326    }
13327
13328    #[test]
13329    fn test_nonmatched_route_omits_query_attr_from_dom() {
13330        let protocol = make_query_route_protocol();
13331        let state = test_json!({});
13332        let handler = WebUIHandler::new();
13333        let mut writer = TestWriter::new();
13334
13335        handler
13336            .handle(
13337                &protocol,
13338                &state,
13339                &RenderOptions::new("index.html", "/settings"),
13340                &mut writer,
13341            )
13342            .expect("render failed");
13343
13344        let html = writer.get_content();
13345        // query attr should not be on hidden siblings either
13346        assert!(
13347            !html.contains(r#"query="#),
13348            "hidden route should not have query attr: {html}"
13349        );
13350    }
13351
13352    #[test]
13353    fn test_route_without_query_has_no_query_attr() {
13354        let protocol = make_query_route_protocol();
13355        let state = test_json!({});
13356        let handler = WebUIHandler::new();
13357        let mut writer = TestWriter::new();
13358
13359        handler
13360            .handle(
13361                &protocol,
13362                &state,
13363                &RenderOptions::new("index.html", "/settings"),
13364                &mut writer,
13365            )
13366            .expect("render failed");
13367
13368        let html = writer.get_content();
13369        // Find the settings route element and verify it has no query attr
13370        let settings_idx = html
13371            .find(r#"component="settings-page""#)
13372            .expect("settings route should exist");
13373        let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
13374        assert!(
13375            !settings_tag.contains("query="),
13376            "route without allowed_query should not emit query attr: {settings_tag}"
13377        );
13378    }
13379
13380    // ── Per-render head_inject / body_inject (replaces the byte-scanner
13381    //    InjectingStreamingWriter approach with structural signal-based
13382    //    injection) ───────────────────────────────────────────────────
13383
13384    fn build_head_body_protocol() -> WebUIProtocol {
13385        let mut fragments = HashMap::new();
13386        fragments.insert(
13387            "index.html".to_string(),
13388            FragmentList {
13389                fragments: vec![
13390                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13391                    structural_fragment("head_end"),
13392                    WebUIFragment::raw("</head><body>hello".to_string()),
13393                    structural_fragment("body_end"),
13394                    WebUIFragment::raw("</body></html>".to_string()),
13395                ],
13396                contains_boundary: false,
13397            },
13398        );
13399        WebUIProtocol::new(fragments)
13400    }
13401
13402    #[test]
13403    fn head_inject_emits_at_head_end_boundary() {
13404        let protocol = build_head_body_protocol();
13405        let state = test_json!({});
13406        let mut writer = TestWriter::new();
13407        let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
13408        handle(&protocol, &state, &opts, &mut writer).unwrap();
13409        let html = writer.get_content();
13410        // The inject must appear immediately before `</head>`.
13411        let inject_idx = html
13412            .find("<link rel=preload>")
13413            .expect("inject HTML missing");
13414        let head_close_idx = html.find("</head>").expect("</head> missing");
13415        assert!(
13416            inject_idx < head_close_idx,
13417            "head_inject must appear before </head>: {html}"
13418        );
13419        // No duplicate.
13420        assert_eq!(html.matches("<link rel=preload>").count(), 1);
13421    }
13422
13423    #[test]
13424    fn component_render_policy_css_emits_once_in_head_with_nonce() {
13425        let mut protocol = build_head_body_protocol();
13426        protocol.component_render_css = concat!(
13427            r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
13428            "contain-intrinsic-block-size:auto 18rem;}"
13429        )
13430        .to_string();
13431        let state = test_json!({});
13432        let mut writer = TestWriter::new();
13433        let opts = RenderOptions::new("index.html", "/").with_nonce("test-nonce");
13434        handle(&protocol, &state, &opts, &mut writer).unwrap();
13435        let html = writer.get_content();
13436
13437        let style = concat!(
13438            r#"<style data-webui-render-policy nonce="test-nonce">"#,
13439            r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
13440            "contain-intrinsic-block-size:auto 18rem;}</style>"
13441        );
13442        let style_index = html.find(style).expect("render policy style missing");
13443        let head_close = html.find("</head>").expect("</head> missing");
13444        assert!(style_index < head_close);
13445        assert_eq!(html.matches("data-webui-render-policy").count(), 1);
13446    }
13447
13448    #[test]
13449    fn body_inject_emits_at_body_end_boundary() {
13450        let protocol = build_head_body_protocol();
13451        let state = test_json!({});
13452        let mut writer = TestWriter::new();
13453        let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
13454        handle(&protocol, &state, &opts, &mut writer).unwrap();
13455        let html = writer.get_content();
13456        let inject_idx = html
13457            .find("<script>lr</script>")
13458            .expect("inject HTML missing");
13459        let body_close_idx = html.find("</body>").expect("</body> missing");
13460        assert!(
13461            inject_idx < body_close_idx,
13462            "body_inject must appear before </body>: {html}"
13463        );
13464        assert_eq!(html.matches("<script>lr</script>").count(), 1);
13465    }
13466
13467    // ── Reserved `$webui` state inject namespace ──────────────────────
13468
13469    /// Protocol carrying all three structural boundaries, so `body_start`
13470    /// placement can be asserted alongside `head_end` / `body_end`.
13471    fn build_all_boundaries_protocol() -> WebUIProtocol {
13472        let mut fragments = HashMap::new();
13473        fragments.insert(
13474            "index.html".to_string(),
13475            FragmentList {
13476                fragments: vec![
13477                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13478                    structural_fragment("head_end"),
13479                    WebUIFragment::raw("</head><body>".to_string()),
13480                    structural_fragment("body_start"),
13481                    WebUIFragment::raw("hello".to_string()),
13482                    structural_fragment("body_end"),
13483                    WebUIFragment::raw("</body></html>".to_string()),
13484                ],
13485                contains_boundary: false,
13486            },
13487        );
13488        WebUIProtocol::new(fragments)
13489    }
13490
13491    fn state_inject_options<'a>() -> RenderOptions<'a> {
13492        RenderOptions::new("index.html", "/")
13493    }
13494
13495    fn render_with(protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>) -> String {
13496        let mut writer = TestWriter::new();
13497        handle(protocol, state, options, &mut writer).unwrap();
13498        writer.get_content().to_string()
13499    }
13500
13501    #[test]
13502    fn state_inject_emits_at_every_structural_boundary() {
13503        let protocol = build_all_boundaries_protocol();
13504        let state = test_json!({
13505            "$webui": {
13506                "headEnd": "<meta name=he>",
13507                "bodyStart": "<span id=bs></span>",
13508                "bodyEnd": "<script>be</script>",
13509            }
13510        });
13511        let html = render_with(&protocol, &state, &state_inject_options());
13512
13513        let head_end = html.find("<meta name=he>").expect("headEnd missing");
13514        let head_close = html.find("</head>").expect("</head> missing");
13515        let body_open = html.find("<body>").expect("<body> missing");
13516        let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
13517        let hello = html.find("hello").expect("body content missing");
13518        let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
13519        let body_close = html.find("</body>").expect("</body> missing");
13520
13521        assert!(
13522            head_end < head_close,
13523            "headEnd must precede </head>: {html}"
13524        );
13525        assert!(
13526            body_open < body_start && body_start < hello,
13527            "bodyStart must sit immediately after <body>: {html}"
13528        );
13529        assert!(
13530            hello < body_end && body_end < body_close,
13531            "bodyEnd must precede </body>: {html}"
13532        );
13533
13534        for needle in [
13535            "<meta name=he>",
13536            "<span id=bs></span>",
13537            "<script>be</script>",
13538        ] {
13539            assert_eq!(html.matches(needle).count(), 1, "duplicated {needle}");
13540        }
13541    }
13542
13543    #[test]
13544    fn state_inject_follows_render_options_inject() {
13545        let protocol = build_all_boundaries_protocol();
13546        let state = test_json!({
13547            "$webui": { "headEnd": "<!--state-he-->", "bodyEnd": "<!--state-be-->" }
13548        });
13549        let options = RenderOptions::new("index.html", "/")
13550            .with_head_inject("<!--opt-he-->")
13551            .with_body_inject("<!--opt-be-->");
13552        let html = render_with(&protocol, &state, &options);
13553
13554        let opt_he = html.find("<!--opt-he-->").expect("option headEnd missing");
13555        let state_he = html.find("<!--state-he-->").expect("state headEnd missing");
13556        let opt_be = html.find("<!--opt-be-->").expect("option bodyEnd missing");
13557        let state_be = html.find("<!--state-be-->").expect("state bodyEnd missing");
13558
13559        assert!(
13560            opt_he < state_he,
13561            "RenderOptions head_inject must precede the state-supplied value: {html}"
13562        );
13563        assert!(
13564            opt_be < state_be,
13565            "RenderOptions body_inject must precede the state-supplied value: {html}"
13566        );
13567    }
13568
13569    #[test]
13570    fn malformed_state_inject_values_are_inert() {
13571        let protocol = build_all_boundaries_protocol();
13572        // Absent key, wrong container type, and per-member null / empty /
13573        // non-string values must all render without output and without error.
13574        for state in [
13575            test_json!({}),
13576            test_json!({ "$webui": "not-an-object" }),
13577            test_json!({ "$webui": [] }),
13578            test_json!({ "$webui": null }),
13579            test_json!({ "$webui": { "headEnd": null, "bodyStart": "", "bodyEnd": 42 } }),
13580            test_json!({ "$webui": { "unknownMember": "<b>x</b>" } }),
13581        ] {
13582            let html = render_with(&protocol, &state, &state_inject_options());
13583            assert!(
13584                html.contains("<html><head><title>x</title></head><body>hello</body></html>"),
13585                "malformed reserved state must render the document unchanged: {html}"
13586            );
13587            assert!(!html.contains("<b>x</b>"), "unknown member leaked: {html}");
13588            assert!(!html.contains("42"), "non-string member leaked: {html}");
13589        }
13590    }
13591
13592    #[test]
13593    fn state_inject_never_reaches_the_hydration_payload() {
13594        let protocol = build_all_boundaries_protocol();
13595        let state = test_json!({
13596            "visible": "keep",
13597            "$webui": { "bodyEnd": "<script>secret</script>" }
13598        });
13599        let handler = WebUIHandler::with_plugin(|| {
13600            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13601        });
13602        let runtime = Protocol::new(protocol.clone());
13603        let mut writer = TestWriter::new();
13604        handler
13605            .render(&runtime, &state, &state_inject_options(), &mut writer)
13606            .unwrap();
13607        let html = writer.get_content();
13608
13609        let data_start = html
13610            .find(r#"<script type="application/json" id="webui-data""#)
13611            .expect("hydration block missing");
13612        let data_end = html[data_start..]
13613            .find("</script>")
13614            .map(|offset| data_start + offset)
13615            .expect("hydration block never closes");
13616        let payload = &html[data_start..data_end];
13617        assert!(
13618            !payload.contains("$webui"),
13619            "reserved key must be stripped from the hydration payload: {payload}"
13620        );
13621        assert!(
13622            payload.contains("visible"),
13623            "ordinary state must survive the filter: {payload}"
13624        );
13625        // Per the documented precedence the injected HTML is emitted after
13626        // the built-in hydration block, and still before `</body>`.
13627        let inject = html
13628            .find("<script>secret</script>")
13629            .expect("inject missing");
13630        let body_close = html.find("</body>").expect("</body> missing");
13631        assert!(
13632            data_end < inject && inject < body_close,
13633            "state bodyEnd must follow the hydration block and precede </body>: {html}"
13634        );
13635    }
13636
13637    #[test]
13638    fn projected_hydration_strips_reserved_state_inject_key() {
13639        let mut fragments = HashMap::new();
13640        fragments.insert(
13641            "index.html".to_string(),
13642            FragmentList {
13643                fragments: vec![
13644                    WebUIFragment::raw("<html><body>"),
13645                    WebUIFragment::component("app-shell"),
13646                    structural_fragment("body_end"),
13647                    WebUIFragment::raw("</body></html>"),
13648                ],
13649                contains_boundary: false,
13650            },
13651        );
13652        fragments.insert(
13653            "app-shell".to_string(),
13654            FragmentList {
13655                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
13656                contains_boundary: false,
13657            },
13658        );
13659        let mut document = WebUIProtocol::new(fragments);
13660        document.initial_state_strategy = InitialStateStrategy::Components as i32;
13661        document.components.insert(
13662            "app-shell".to_string(),
13663            webui_protocol::ComponentData {
13664                hydration_mode: StateProjectionMode::Keys as i32,
13665                hydration_keys: vec![STATE_INJECT_KEY.to_string(), "visible".to_string()],
13666                ..Default::default()
13667            },
13668        );
13669        let state = test_json!({
13670            "$webui": { "bodyEnd": "<script>secret</script>" },
13671            "serverOnly": "drop",
13672            "visible": "keep",
13673        });
13674        let handler = WebUIHandler::with_plugin(|| {
13675            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13676        });
13677        let mut writer = TestWriter::new();
13678
13679        handler
13680            .render(
13681                &Protocol::new(document),
13682                &state,
13683                &RenderOptions::new("index.html", "/"),
13684                &mut writer,
13685            )
13686            .unwrap();
13687        let html = writer.get_content();
13688        let data_start = html
13689            .find(r#"<script type="application/json" id="webui-data""#)
13690            .expect("hydration block missing");
13691        let data_end = html[data_start..]
13692            .find("</script>")
13693            .map(|offset| data_start + offset)
13694            .expect("hydration block never closes");
13695        let payload = &html[data_start..data_end];
13696
13697        assert!(
13698            !payload.contains(STATE_INJECT_KEY),
13699            "reserved key leaked: {payload}"
13700        );
13701        assert!(payload.contains(r#""visible":"keep""#), "{payload}");
13702        assert!(!payload.contains("serverOnly"), "{payload}");
13703        assert!(html.contains("<script>secret</script>"), "{html}");
13704    }
13705
13706    #[test]
13707    fn write_selected_state_strips_reserved_key_from_full_state() {
13708        let state = test_json!({ "a": 1, "$webui": { "bodyEnd": "<b>x</b>" }, "z": 2 });
13709        let mut sink = TestWriter::new();
13710        let mut scratch = Vec::new();
13711        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13712        let json = sink.get_content();
13713        assert!(!json.contains("$webui"), "reserved key leaked: {json}");
13714        assert!(
13715            json.contains("\"a\":1") && json.contains("\"z\":2"),
13716            "{json}"
13717        );
13718    }
13719
13720    #[test]
13721    fn write_selected_state_strips_reserved_key_from_key_id_projection() {
13722        // The streaming record projects through interned key IDs, so the
13723        // reserved inject key must be filtered on that path too.
13724        let mut protocol = WebUIProtocol::new(HashMap::new());
13725        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13726        protocol.fragments.insert(
13727            "keep-card".to_string(),
13728            webui_protocol::FragmentList::default(),
13729        );
13730        protocol.components.insert(
13731            "keep-card".to_string(),
13732            webui_protocol::ComponentData {
13733                hydration_mode: StateProjectionMode::Keys as i32,
13734                hydration_keys: vec![STATE_INJECT_KEY.to_string(), "keep".to_string()],
13735                ..Default::default()
13736            },
13737        );
13738        let protocol = Protocol::new(protocol);
13739        let index = protocol.component_reachability();
13740        let component = protocol.component_index()["keep-card"];
13741        let mut ids = Vec::new();
13742        assert!(!collect_hydration_key_ids_into(
13743            protocol.protocol(),
13744            index,
13745            [component],
13746            &mut ids
13747        ));
13748
13749        let state = test_json!({
13750            "$webui": { "bodyEnd": "<b>x</b>" },
13751            "keep": 1,
13752        });
13753        let mut sink = TestWriter::new();
13754        let mut scratch = Vec::new();
13755
13756        write_selected_state(
13757            &mut sink,
13758            &mut scratch,
13759            &state,
13760            &StateSelection::KeyIds(HydrationKeySelection { ids: &ids, index }),
13761        )
13762        .unwrap();
13763
13764        assert_eq!(sink.get_content(), r#"{"keep":1}"#);
13765    }
13766
13767    #[test]
13768    fn write_selected_state_full_is_unchanged_without_reserved_key() {
13769        let state = test_json!({ "a": 1, "z": 2 });
13770        let mut sink = TestWriter::new();
13771        let mut scratch = Vec::new();
13772        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13773        assert_eq!(sink.get_content(), r#"{"a":1,"z":2}"#);
13774    }
13775
13776    #[test]
13777    fn body_start_hook_dedupes_on_malformed_protocol() {
13778        let mut fragments = HashMap::new();
13779        fragments.insert(
13780            "index.html".to_string(),
13781            FragmentList {
13782                fragments: vec![
13783                    WebUIFragment::raw("<html><head></head><body>".to_string()),
13784                    structural_fragment("body_start"),
13785                    structural_fragment("body_start"),
13786                    WebUIFragment::raw("</body></html>".to_string()),
13787                ],
13788                contains_boundary: false,
13789            },
13790        );
13791        let protocol = WebUIProtocol::new(fragments);
13792        let state = test_json!({ "$webui": { "bodyStart": "<i>once</i>" } });
13793        let html = render_with(&protocol, &state, &state_inject_options());
13794        assert_eq!(
13795            html.matches("<i>once</i>").count(),
13796            1,
13797            "duplicate body_start signals must not duplicate the inject: {html}"
13798        );
13799    }
13800
13801    #[test]
13802    fn injects_are_no_op_when_unset() {
13803        let protocol = build_head_body_protocol();
13804        let state = test_json!({});
13805        let mut writer = TestWriter::new();
13806        handle(
13807            &protocol,
13808            &state,
13809            &RenderOptions::new("index.html", "/"),
13810            &mut writer,
13811        )
13812        .unwrap();
13813        let html = writer.get_content();
13814        assert!(!html.contains("<link rel=preload>"));
13815        assert!(!html.contains("<script>lr</script>"));
13816    }
13817
13818    #[test]
13819    fn empty_inject_string_treated_as_unset() {
13820        let protocol = build_head_body_protocol();
13821        let state = test_json!({});
13822        let mut writer = TestWriter::new();
13823        let opts = RenderOptions::new("index.html", "/")
13824            .with_head_inject("")
13825            .with_body_inject("");
13826        handle(&protocol, &state, &opts, &mut writer).unwrap();
13827        // No injection happens — empty strings are normalised to None
13828        // by the builder, so the output is identical to the no-options case.
13829        let html = writer.get_content();
13830        assert!(html.contains("</head>"));
13831        assert!(html.contains("</body>"));
13832    }
13833
13834    #[test]
13835    fn inject_html_is_passed_through_verbatim() {
13836        // The handler does NOT escape the inject string — hosts pass
13837        // raw HTML they trust. This test pins that contract: a `<` in
13838        // the inject is emitted as-is, not encoded as `&lt;`.
13839        let protocol = build_head_body_protocol();
13840        let state = test_json!({});
13841        let mut writer = TestWriter::new();
13842        let opts =
13843            RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
13844        handle(&protocol, &state, &opts, &mut writer).unwrap();
13845        assert!(writer.get_content().contains("<script>var x=1;</script>"));
13846    }
13847
13848    /// Both injects fire and appear at the correct structural
13849    /// positions. Critically, this is robust against `</head>` /
13850    /// `</body>` literals appearing elsewhere in the document — the
13851    /// signal-based emitter cannot mis-fire on byte patterns inside
13852    /// HTML comments, `<iframe srcdoc>`, or inline scripts (which the
13853    /// previous byte-scanner could).
13854    #[test]
13855    fn injects_robust_against_marker_literals_in_content() {
13856        let mut fragments = HashMap::new();
13857        // The body intentionally contains `</body>` and `</head>`
13858        // literals before the actual structural close — these came
13859        // from a (hypothetical) iframe srcdoc or comment.
13860        fragments.insert(
13861            "index.html".to_string(),
13862            FragmentList {
13863                fragments: vec![
13864                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13865                    structural_fragment("head_end"),
13866                    WebUIFragment::raw(
13867                        "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
13868                    ),
13869                    structural_fragment("body_end"),
13870                    WebUIFragment::raw("</body></html>".to_string()),
13871                ],
13872                contains_boundary: false,
13873            },
13874        );
13875        let protocol = WebUIProtocol::new(fragments);
13876        let state = test_json!({});
13877        let mut writer = TestWriter::new();
13878        let opts = RenderOptions::new("index.html", "/")
13879            .with_head_inject("<HEAD-INJ>")
13880            .with_body_inject("<BODY-INJ>");
13881        handle(&protocol, &state, &opts, &mut writer).unwrap();
13882        let html = writer.get_content();
13883        // The head inject sits between `<title>x</title>` and the
13884        // first `</head>` — the structural one, not the comment one.
13885        let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
13886        let head_close_idx = html.find("</head>").expect("</head> missing");
13887        assert!(head_inj_idx < head_close_idx);
13888        // The body inject sits before the structural `</body>` — NOT
13889        // before the `</body>` literal in the comment (which would
13890        // require the inject to appear inside `<p>hi</p>` somewhere).
13891        let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
13892        // Find the LAST `</body>` (the structural one).
13893        let body_close_idx = html.rfind("</body>").expect("</body> missing");
13894        assert!(
13895            body_inj_idx < body_close_idx,
13896            "body_inject must precede the structural </body>: {html}"
13897        );
13898        // And the comment is preserved verbatim.
13899        assert!(html.contains("<!-- </body> </head> -->"));
13900    }
13901
13902    /// Coverage-14: both `head_inject` AND `body_inject` set in the
13903    /// same render. Each fires at the correct structural boundary and
13904    /// neither leaks into the other's region.
13905    #[test]
13906    fn both_injects_fire_at_correct_boundaries() {
13907        let protocol = build_head_body_protocol();
13908        let state = test_json!({});
13909        let mut writer = TestWriter::new();
13910        let opts = RenderOptions::new("index.html", "/")
13911            .with_head_inject("<META-HEAD>")
13912            .with_body_inject("<SCRIPT-BODY>");
13913        handle(&protocol, &state, &opts, &mut writer).unwrap();
13914        let html = writer.get_content();
13915        let head_idx = html.find("<META-HEAD>").expect("head inject missing");
13916        let head_close = html.find("</head>").expect("</head> missing");
13917        let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
13918        let body_close = html.find("</body>").expect("</body> missing");
13919        assert!(head_idx < head_close, "head_inject before </head>");
13920        assert!(head_close < body_idx, "body_inject after </head>");
13921        assert!(body_idx < body_close, "body_inject before </body>");
13922        assert_eq!(html.matches("<META-HEAD>").count(), 1);
13923        assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
13924    }
13925
13926    /// Coverage-15 / Bug-3 (security defense): a malformed protocol
13927    /// emitting `head_end` and `body_end` more than once must NOT
13928    /// duplicate the host inject HTML. Without the dedup guard,
13929    /// double-emission would amplify Security-2 (a 1 MiB inject ×
13930    /// 1000 duplicate signals = 1 GiB output).
13931    #[test]
13932    fn injects_dedupe_against_duplicate_signals() {
13933        let mut fragments = HashMap::new();
13934        fragments.insert(
13935            "index.html".to_string(),
13936            FragmentList {
13937                fragments: vec![
13938                    WebUIFragment::raw("<html><head>".to_string()),
13939                    structural_fragment("head_end"),
13940                    structural_fragment("head_end"), // duplicate
13941                    structural_fragment("head_end"), // triplicate
13942                    WebUIFragment::raw("</head><body>".to_string()),
13943                    structural_fragment("body_end"),
13944                    structural_fragment("body_end"), // duplicate
13945                    WebUIFragment::raw("</body></html>".to_string()),
13946                ],
13947                contains_boundary: false,
13948            },
13949        );
13950        let protocol = WebUIProtocol::new(fragments);
13951        let state = test_json!({});
13952        let mut writer = TestWriter::new();
13953        let opts = RenderOptions::new("index.html", "/")
13954            .with_head_inject("<HINJ>")
13955            .with_body_inject("<BINJ>");
13956        handle(&protocol, &state, &opts, &mut writer).unwrap();
13957        let html = writer.get_content();
13958        assert_eq!(
13959            html.matches("<HINJ>").count(),
13960            1,
13961            "head_inject must emit exactly once even with duplicate head_end signals"
13962        );
13963        assert_eq!(
13964            html.matches("<BINJ>").count(),
13965            1,
13966            "body_inject must emit exactly once even with duplicate body_end signals"
13967        );
13968    }
13969
13970    /// Coverage-15: a Shadow-DOM / component-only protocol that has NO
13971    /// `<head>` / `<body>` tags must NOT emit the inject (the signals
13972    /// never fire). Verifies the injects are no-ops, not panics.
13973    #[test]
13974    fn injects_no_op_when_no_head_or_body_signals() {
13975        let mut fragments = HashMap::new();
13976        fragments.insert(
13977            "index.html".to_string(),
13978            FragmentList {
13979                fragments: vec![WebUIFragment::raw(
13980                    "<my-component>hi</my-component>".to_string(),
13981                )],
13982                contains_boundary: false,
13983            },
13984        );
13985        let protocol = WebUIProtocol::new(fragments);
13986        let state = test_json!({});
13987        let mut writer = TestWriter::new();
13988        let opts = RenderOptions::new("index.html", "/")
13989            .with_head_inject("<HINJ>")
13990            .with_body_inject("<BINJ>");
13991        handle(&protocol, &state, &opts, &mut writer).unwrap();
13992        let html = writer.get_content();
13993        assert!(!html.contains("<HINJ>"), "head_inject must not appear");
13994        assert!(!html.contains("<BINJ>"), "body_inject must not appear");
13995        assert!(html.contains("<my-component>"));
13996    }
13997
13998    /// Coverage-19: the handler's `&self` is shared across threads.
13999    /// Two concurrent renders with different inject values must NOT
14000    /// cross-contaminate (each thread sees only its own inject).
14001    /// Per-render mutable state lives on the `WebUIProcessContext`,
14002    /// which is stack-allocated per call.
14003    #[test]
14004    fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
14005        let protocol = std::sync::Arc::new(build_head_body_protocol());
14006        let state = std::sync::Arc::new(test_json!({}));
14007        let handler = std::sync::Arc::new(WebUIHandler::new());
14008
14009        const N_THREADS: usize = 16;
14010        let mut handles = Vec::with_capacity(N_THREADS);
14011        for tid in 0..N_THREADS {
14012            let h = std::sync::Arc::clone(&handler);
14013            let p = std::sync::Arc::clone(&protocol);
14014            let s = std::sync::Arc::clone(&state);
14015            handles.push(std::thread::spawn(move || {
14016                let head = format!("<HEAD-T{tid}>");
14017                let body = format!("<BODY-T{tid}>");
14018                let mut writer = TestWriter::new();
14019                let opts = RenderOptions::new("index.html", "/")
14020                    .with_head_inject(&head)
14021                    .with_body_inject(&body);
14022                h.handle(&p, &s, &opts, &mut writer).unwrap();
14023                let html = writer.get_content();
14024                // Must contain my own injects exactly once.
14025                assert_eq!(html.matches(&head).count(), 1);
14026                assert_eq!(html.matches(&body).count(), 1);
14027                // Must NOT contain any other thread's inject.
14028                for other in 0..N_THREADS {
14029                    if other == tid {
14030                        continue;
14031                    }
14032                    let other_head = format!("<HEAD-T{other}>");
14033                    let other_body = format!("<BODY-T{other}>");
14034                    assert!(
14035                        !html.contains(&other_head),
14036                        "tid {tid} saw {other}'s head_inject"
14037                    );
14038                    assert!(
14039                        !html.contains(&other_body),
14040                        "tid {tid} saw {other}'s body_inject"
14041                    );
14042                }
14043            }));
14044        }
14045        for h in handles {
14046            h.join().expect("worker panicked");
14047        }
14048    }
14049
14050    /// Coverage-17: a large (1 MiB) head_inject must round-trip
14051    /// correctly without panic, truncation, or excessive overhead.
14052    /// (No size cap is enforced by the handler — the host owns the
14053    /// safety contract; see `with_head_inject` doc comment.)
14054    #[test]
14055    fn large_inject_roundtrips_without_truncation() {
14056        let protocol = build_head_body_protocol();
14057        let state = test_json!({});
14058        let mut writer = TestWriter::new();
14059        let big = "x".repeat(1024 * 1024);
14060        let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
14061        handle(&protocol, &state, &opts, &mut writer).unwrap();
14062        let html = writer.get_content();
14063        assert!(
14064            html.contains(&big),
14065            "large head_inject must be present verbatim ({} bytes)",
14066            big.len()
14067        );
14068        // Sanity: only one copy.
14069        assert_eq!(html.matches(&big).count(), 1);
14070    }
14071
14072    /// `with_nonce("")` must normalize to `None` (no `<meta>` emitted),
14073    /// matching the empty-string semantics of `with_head_inject` /
14074    /// `with_body_inject`. An empty content attribute is browser-
14075    /// ignored noise.
14076    #[test]
14077    fn empty_nonce_treated_as_unset() {
14078        let protocol = build_head_body_protocol();
14079        let state = test_json!({});
14080        let mut writer = TestWriter::new();
14081        let opts = RenderOptions::new("index.html", "/").with_nonce("");
14082        handle(&protocol, &state, &opts, &mut writer).unwrap();
14083        assert!(
14084            !writer.get_content().contains("webui-nonce"),
14085            "empty nonce must not emit <meta name=\"webui-nonce\">"
14086        );
14087    }
14088
14089    /// Regression for the bug Akrosh caught: the `pub` fields on
14090    /// `RenderOptions` let a caller bypass the `with_*` builder
14091    /// normalisation, e.g.:
14092    ///
14093    /// ```ignore
14094    /// RenderOptions { nonce: Some(""), ..RenderOptions::new(e, p) }
14095    /// ```
14096    ///
14097    /// Without defensive normalisation at handler init, this would
14098    /// emit `<script nonce="">` on every inline script. Under a
14099    /// strict `Content-Security-Policy: script-src 'nonce-...'` an
14100    /// empty nonce is a HARD CSP failure that blocks every inline
14101    /// script — a complete inline-script-execution outage.
14102    ///
14103    /// The handler now treats `Some("")` identically to `None` for
14104    /// all three injection points (nonce / head_inject / body_inject)
14105    /// regardless of how the option was populated.
14106    #[test]
14107    fn empty_field_bypass_is_normalised_at_handler_init() {
14108        let protocol = build_head_body_protocol();
14109        let state = test_json!({});
14110
14111        // Bypass the `with_nonce` builder by writing the field directly.
14112        let opts_with_empty_nonce = RenderOptions {
14113            nonce: Some(""),
14114            ..RenderOptions::new("index.html", "/")
14115        };
14116        let mut writer = TestWriter::new();
14117        handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
14118        let html = writer.get_content();
14119        assert!(
14120            !html.contains("webui-nonce"),
14121            "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
14122        );
14123        assert!(
14124            !html.contains("nonce=\"\""),
14125            "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
14126        );
14127
14128        // Same defence for inject fields.
14129        let opts_with_empty_injects = RenderOptions {
14130            head_inject: Some(""),
14131            body_inject: Some(""),
14132            ..RenderOptions::new("index.html", "/")
14133        };
14134        let mut writer = TestWriter::new();
14135        handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
14136        // No assertion needed beyond "doesn't panic and doesn't emit
14137        // empty inject markers" — the head_end / body_end paths must
14138        // treat the empty inject as no-op the same way the builder does.
14139    }
14140
14141    /// Regression for the deep-audit's Bug-6 claim. The for-loop hot-
14142    /// path optimisation (insert key once + `get_mut`-swap value
14143    /// in-place) was suspected of corrupting the outer scope when a
14144    /// nested `<for>` loop reuses the same variable name. This test
14145    /// proves the optimisation is correct under that condition by
14146    /// requiring the outer `item` to be visible before, between, and
14147    /// after the inner loop, with its value preserved across inner
14148    /// iterations.
14149    ///
14150    /// Trace through the optimisation on `outer = [A, B]`,
14151    /// `inner = [X, Y]` with both loops using `item` as the variable:
14152    ///
14153    ///   outer pre-insert "item": Null
14154    ///   iter 1: get_mut → write A
14155    ///     emit "outer:A"
14156    ///     enter inner: saved = remove("item") = Some(A)
14157    ///                  pre-insert "item": Null
14158    ///                  iter 1: write X → emit "inner:X"
14159    ///                  iter 2: write Y → emit "inner:Y"
14160    ///                  restore: insert("item", A)   ← outer's A back
14161    ///     emit "outer:A again"               ← reads A correctly
14162    ///   iter 2: get_mut → write B (overwrites the restored A,
14163    ///                              but that's correct — we're now
14164    ///                              in iter 2 of the outer loop)
14165    ///     emit "outer:B"
14166    ///     enter inner: saved = remove("item") = Some(B), …, restore B
14167    ///     emit "outer:B again"
14168    ///
14169    /// If the audit's claim were correct — that the outer's `get_mut`
14170    /// somehow held a reference past the inner loop and clobbered the
14171    /// restoration — we'd see corrupted values in the "outer:X again"
14172    /// emissions. The assertion below pins the correct sequence.
14173    #[test]
14174    fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
14175        let mut fragments = HashMap::new();
14176        fragments.insert(
14177            "index.html".to_string(),
14178            FragmentList {
14179                fragments: vec![
14180                    WebUIFragment::raw("["),
14181                    WebUIFragment::for_loop("item", "outer", "outer_body"),
14182                    WebUIFragment::raw("]"),
14183                ],
14184                contains_boundary: false,
14185            },
14186        );
14187        fragments.insert(
14188            "outer_body".to_string(),
14189            FragmentList {
14190                fragments: vec![
14191                    WebUIFragment::raw("(O="),
14192                    WebUIFragment::signal("item.tag", false),
14193                    WebUIFragment::for_loop("item", "inner", "inner_body"),
14194                    WebUIFragment::raw(",O="),
14195                    WebUIFragment::signal("item.tag", false),
14196                    WebUIFragment::raw(")"),
14197                ],
14198                contains_boundary: false,
14199            },
14200        );
14201        fragments.insert(
14202            "inner_body".to_string(),
14203            FragmentList {
14204                fragments: vec![
14205                    WebUIFragment::raw("[I="),
14206                    WebUIFragment::signal("item.tag", false),
14207                    WebUIFragment::raw("]"),
14208                ],
14209                contains_boundary: false,
14210            },
14211        );
14212        let protocol = WebUIProtocol::new(fragments);
14213        let state = test_json!({
14214            "outer": [{"tag": "A"}, {"tag": "B"}],
14215            "inner": [{"tag": "X"}, {"tag": "Y"}],
14216        });
14217        let mut writer = TestWriter::new();
14218        handle(
14219            &protocol,
14220            &state,
14221            &RenderOptions::new("index.html", "/"),
14222            &mut writer,
14223        )
14224        .unwrap();
14225        // Expected sequence:
14226        //   outer iter 1 (item=A):
14227        //     emit "(O=A"               ← outer A before inner
14228        //     inner iter 1 (item=X) emit "[I=X]"
14229        //     inner iter 2 (item=Y) emit "[I=Y]"
14230        //     emit ",O=A)"              ← outer A AFTER inner restore
14231        //   outer iter 2 (item=B):
14232        //     emit "(O=B"
14233        //     inner iter 1 (item=X) emit "[I=X]"
14234        //     inner iter 2 (item=Y) emit "[I=Y]"
14235        //     emit ",O=B)"
14236        assert_eq!(
14237            writer.get_content(),
14238            "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
14239            "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
14240        );
14241    }
14242    #[derive(Default)]
14243    struct FlushTestWriter {
14244        output: String,
14245        flushes: Vec<usize>,
14246        fail_flush: bool,
14247        fail_flush_at: Option<usize>,
14248        flush_attempts: usize,
14249        ended: bool,
14250    }
14251
14252    impl ResponseWriter for FlushTestWriter {
14253        fn write(&mut self, content: &str) -> Result<()> {
14254            self.output.push_str(content);
14255            Ok(())
14256        }
14257
14258        fn end(&mut self) -> Result<()> {
14259            self.ended = true;
14260            Ok(())
14261        }
14262    }
14263
14264    impl FlushWriter for FlushTestWriter {
14265        fn flush(&mut self) -> Result<()> {
14266            let attempt = self.flush_attempts;
14267            self.flush_attempts += 1;
14268            if self.fail_flush || self.fail_flush_at == Some(attempt) {
14269                return Err(HandlerError::ClientDisconnected);
14270            }
14271            self.flushes.push(self.output.len());
14272            Ok(())
14273        }
14274    }
14275
14276    fn streaming_protocol(with_boundaries: bool) -> Protocol {
14277        streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
14278    }
14279
14280    fn streaming_protocol_with_state_strategy(
14281        with_boundaries: bool,
14282        state_strategy: InitialStateStrategy,
14283    ) -> Protocol {
14284        let mut fragments = HashMap::new();
14285        let mut entry = vec![
14286            WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
14287            structural_fragment("head_start"),
14288            WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
14289            structural_fragment("head_end"),
14290            WebUIFragment::raw("</HEAD><body>"),
14291            structural_fragment("body_start"),
14292        ];
14293        if with_boundaries {
14294            entry.push(WebUIFragment::boundary(0, "index.html", "boundary-0", None));
14295        }
14296        entry.extend([
14297            WebUIFragment::raw("<my-counter"),
14298            structural_fragment("streaming_root:my-counter"),
14299            WebUIFragment::raw(">"),
14300            WebUIFragment::component("my-counter"),
14301            WebUIFragment::raw("</my-counter>"),
14302        ]);
14303        if with_boundaries {
14304            entry.push(WebUIFragment::boundary_end(0));
14305        }
14306        entry.extend([
14307            WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
14308            structural_fragment("body_end"),
14309            WebUIFragment::raw("</body></html>"),
14310        ]);
14311        fragments.insert(
14312            "index.html".to_string(),
14313            FragmentList {
14314                fragments: entry,
14315                contains_boundary: with_boundaries,
14316            },
14317        );
14318        fragments.insert(
14319            "my-counter".to_string(),
14320            FragmentList {
14321                fragments: vec![WebUIFragment::raw("<button>Count</button>")],
14322                contains_boundary: false,
14323            },
14324        );
14325
14326        let mut document = WebUIProtocol::new(fragments);
14327        document.initial_state_strategy = state_strategy as i32;
14328        document.components.insert(
14329            "my-counter".to_string(),
14330            webui_protocol::ComponentData {
14331                template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
14332                template_functions: "[function(){return true}]".to_string(),
14333                hydration_mode: StateProjectionMode::Keys as i32,
14334                hydration_keys: vec!["count".to_string()],
14335                ..Default::default()
14336            },
14337        );
14338        Protocol::new(document)
14339    }
14340
14341    #[test]
14342    fn handler_error_stays_small() {
14343        // Boxing the cold `StreamingBoundary` payload keeps `HandlerError` — and
14344        // therefore `Result<(), HandlerError>` threaded through the hot legacy
14345        // render path — down to a single `String`-sized payload plus a
14346        // discriminant word. If the boundary payload is un-boxed back to
14347        // `{ signal, reason }` it grows to two `String`s (48-byte payload) and
14348        // this fails.
14349        assert!(
14350            std::mem::size_of::<HandlerError>()
14351                <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
14352            "HandlerError grew to {} bytes",
14353            std::mem::size_of::<HandlerError>()
14354        );
14355    }
14356
14357    #[test]
14358    fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
14359        let protocol = streaming_protocol(true);
14360        let handler = WebUIHandler::with_plugin(|| {
14361            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14362        });
14363        let state = test_json!({ "count": 1, "serverOnly": "secret" });
14364        let mut writer = FlushTestWriter::default();
14365
14366        handler
14367            .render_streaming(
14368                &protocol,
14369                &state,
14370                &RenderOptions::new("index.html", "/"),
14371                &mut writer,
14372            )
14373            .unwrap();
14374
14375        assert!(writer.ended);
14376        assert_eq!(
14377            writer.flushes.len(),
14378            3,
14379            "bootstrap, boundary commit, and one coalesced terminal-tail flush"
14380        );
14381        let boundary_flush = &writer.output[writer.flushes[0]..writer.flushes[1]];
14382        assert!(boundary_flush.contains("<!--wb:0-->"));
14383        assert!(boundary_flush.contains("<!--/wb:0-->"));
14384        assert!(
14385            boundary_flush.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
14386            "boundary flush: {boundary_flush}"
14387        );
14388        assert!(boundary_flush.contains(r#""inventory":"01","state":{"count":1}"#));
14389        assert!(boundary_flush.contains(r#""templates":{"my-counter":"#));
14390        assert!(!boundary_flush.contains("slow tail"));
14391        assert!(!writer.output.contains("id=\"webui-data\""));
14392        // The terminal flush commits the scriptless tail without manufacturing
14393        // another state/template projection.
14394        let terminal_flush = &writer.output[writer.flushes[1]..writer.flushes[2]];
14395        assert!(terminal_flush.contains("slow tail"));
14396        assert!(writer.output.contains("[1,4,0,{}]"));
14397        assert!(!writer.output.contains("[1,0,1,"));
14398        assert!(!writer.output.contains("[2,4,0,{}]"));
14399
14400        let marker = writer
14401            .output
14402            .find(STREAMING_MARKER)
14403            .expect("streaming marker");
14404        let attributed_head = writer
14405            .output
14406            .find("<HEAD data-shell=\"main\">")
14407            .expect("attributed mixed-case head");
14408        let authored_script = writer
14409            .output
14410            .find("src=\"/index.js\"")
14411            .expect("entry script");
14412        assert!(attributed_head < marker && marker < authored_script);
14413    }
14414
14415    #[test]
14416    fn streaming_terminal_tail_never_resends_full_state() {
14417        let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
14418        let handler = WebUIHandler::with_plugin(|| {
14419            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14420        });
14421        let mut writer = FlushTestWriter::default();
14422
14423        handler
14424            .render_streaming(
14425                &protocol,
14426                &test_json!({ "count": 1, "serverOnly": "secret" }),
14427                &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
14428                &mut writer,
14429            )
14430            .unwrap();
14431
14432        assert_eq!(
14433            writer.output.matches("serverOnly").count(),
14434            1,
14435            "full state belongs only to the interactive boundary"
14436        );
14437        assert!(writer.output.contains("[1,4,0,{}]"));
14438    }
14439
14440    #[test]
14441    fn streaming_terminal_record_emits_before_body_close() {
14442        let fragments = HashMap::from([(
14443            "index.html".to_string(),
14444            FragmentList {
14445                fragments: vec![
14446                    WebUIFragment::raw("<html><head>"),
14447                    structural_fragment("head_start"),
14448                    structural_fragment("head_end"),
14449                    WebUIFragment::raw("</head><body>"),
14450                    structural_fragment("body_start"),
14451                    WebUIFragment::raw("<main>static</main>"),
14452                    structural_fragment("body_end"),
14453                    WebUIFragment::raw("</body></html>"),
14454                ],
14455                contains_boundary: false,
14456            },
14457        )]);
14458        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14459        let mut writer = FlushTestWriter::default();
14460
14461        WebUIHandler::new()
14462            .render_streaming(
14463                &protocol,
14464                &test_json!({}),
14465                &RenderOptions::new("index.html", "/"),
14466                &mut writer,
14467            )
14468            .unwrap();
14469
14470        let terminal = writer.output.find("[0,4,0,{}]").expect("terminal");
14471        let body_close = writer.output.find("</body>").expect("body close");
14472        assert!(
14473            terminal < body_close,
14474            "terminal boundary record must stay inside <body>: {}",
14475            writer.output
14476        );
14477    }
14478
14479    /// Streaming must place the reserved-state injects exactly where the
14480    /// ordinary render does, so a host can switch modes without its
14481    /// boundary HTML moving.
14482    #[test]
14483    fn state_inject_placement_matches_between_render_modes() {
14484        let entry = vec![
14485            WebUIFragment::raw("<html><head>"),
14486            structural_fragment("head_start"),
14487            structural_fragment("head_end"),
14488            WebUIFragment::raw("</head><body>"),
14489            structural_fragment("body_start"),
14490            WebUIFragment::raw("<main>static</main>"),
14491            structural_fragment("body_end"),
14492            WebUIFragment::raw("</body></html>"),
14493        ];
14494        let fragments = HashMap::from([(
14495            "index.html".to_string(),
14496            FragmentList {
14497                fragments: entry,
14498                contains_boundary: false,
14499            },
14500        )]);
14501        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14502        let state = test_json!({
14503            "$webui": {
14504                "headEnd": "<meta name=he>",
14505                "bodyStart": "<span id=bs></span>",
14506                "bodyEnd": "<script>be</script>",
14507            }
14508        });
14509        let options = RenderOptions::new("index.html", "/");
14510
14511        let mut ordinary = TestWriter::new();
14512        WebUIHandler::new()
14513            .render(&protocol, &state, &options, &mut ordinary)
14514            .unwrap();
14515        let ordinary_html = ordinary.get_content().to_string();
14516
14517        let mut streamed = FlushTestWriter::default();
14518        WebUIHandler::new()
14519            .render_streaming(&protocol, &state, &options, &mut streamed)
14520            .unwrap();
14521        let streamed_html = &streamed.output;
14522
14523        for html in [ordinary_html.as_str(), streamed_html.as_str()] {
14524            let head_end = html.find("<meta name=he>").expect("headEnd missing");
14525            let head_close = html.find("</head>").expect("</head> missing");
14526            let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
14527            let main = html.find("<main>static</main>").expect("content missing");
14528            let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
14529            let body_close = html.find("</body>").expect("</body> missing");
14530            assert!(head_end < head_close, "headEnd misplaced: {html}");
14531            assert!(body_start < main, "bodyStart misplaced: {html}");
14532            assert!(
14533                main < body_end && body_end < body_close,
14534                "bodyEnd misplaced: {html}"
14535            );
14536        }
14537
14538        // The streaming response still terminates with its single empty
14539        // terminal record: an inject must not perturb the record stream.
14540        assert!(
14541            streamed_html.contains(",4,0,{}]"),
14542            "streaming must still end in one empty terminal record: {streamed_html}"
14543        );
14544    }
14545
14546    #[test]
14547    fn streaming_state_inject_emits_and_strips_reserved_key() {
14548        let entry = vec![
14549            WebUIFragment::raw("<html><head>"),
14550            structural_fragment("head_start"),
14551            structural_fragment("head_end"),
14552            WebUIFragment::raw("</head><body>"),
14553            structural_fragment("body_start"),
14554            structural_fragment("body_end"),
14555            WebUIFragment::raw("</body></html>"),
14556        ];
14557        let fragments = HashMap::from([(
14558            "index.html".to_string(),
14559            FragmentList {
14560                fragments: entry,
14561                contains_boundary: false,
14562            },
14563        )]);
14564        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14565        let state = test_json!({ "$webui": { "bodyEnd": "<script>be</script>" } });
14566
14567        let mut writer = FlushTestWriter::default();
14568        WebUIHandler::new()
14569            .render_streaming(
14570                &protocol,
14571                &state,
14572                &RenderOptions::new("index.html", "/"),
14573                &mut writer,
14574            )
14575            .unwrap();
14576        assert!(writer.output.contains("<script>be</script>"));
14577        assert!(!writer.output.contains("$webui"));
14578    }
14579
14580    #[test]
14581    fn static_streaming_document_uses_one_empty_terminal_record() {
14582        let fragments = HashMap::from([(
14583            "index.html".to_string(),
14584            FragmentList {
14585                fragments: vec![
14586                    WebUIFragment::raw("<html><head>"),
14587                    structural_fragment("head_start"),
14588                    structural_fragment("head_end"),
14589                    WebUIFragment::raw("</head><body>"),
14590                    structural_fragment("body_start"),
14591                    WebUIFragment::raw("<main>static</main>"),
14592                    structural_fragment("body_end"),
14593                    WebUIFragment::raw("</body></html>"),
14594                ],
14595                contains_boundary: false,
14596            },
14597        )]);
14598        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14599        let mut writer = FlushTestWriter::default();
14600
14601        WebUIHandler::new()
14602            .render_streaming(
14603                &protocol,
14604                &test_json!({ "serverOnly": "secret" }),
14605                &RenderOptions::new("index.html", "/"),
14606                &mut writer,
14607            )
14608            .unwrap();
14609
14610        assert_eq!(writer.flushes.len(), 1);
14611        assert!(writer.output.contains(STREAMING_MARKER));
14612        assert!(writer.output.contains("[0,4,0,{}]"));
14613        assert!(!writer.output.contains("serverOnly"));
14614        assert!(!writer.output.contains("id=\"webui-data\""));
14615        assert!(!writer.output.contains("<!--wb:"));
14616    }
14617
14618    #[test]
14619    fn streaming_render_emits_nonce_and_functions_before_sentinel() {
14620        let protocol = streaming_protocol(true);
14621        let handler = WebUIHandler::with_plugin(|| {
14622            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14623        });
14624        let mut writer = FlushTestWriter::default();
14625        handler
14626            .render_streaming(
14627                &protocol,
14628                &test_json!({ "count": 1 }),
14629                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
14630                &mut writer,
14631            )
14632            .unwrap();
14633
14634        let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
14635        let envelope = writer.output[boundary_end..]
14636            .find("data-webui-boundary nonce=\"test-nonce-123\"")
14637            .map(|index| index + boundary_end)
14638            .expect("nonce-bearing envelope");
14639        let functions = writer
14640            .output
14641            .find("templateFns")
14642            .expect("function side channel");
14643        let sentinel = writer
14644            .output
14645            .find("<webui-hydrate>")
14646            .expect("hydration sentinel");
14647        assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
14648        assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
14649    }
14650
14651    #[test]
14652    fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
14653        fn protocol(boundaries: Vec<WebUIFragment>) -> Protocol {
14654            let mut fragments = vec![structural_fragment("head_start")];
14655            fragments.extend(boundaries);
14656            let fragments = HashMap::from([(
14657                "index.html".to_string(),
14658                FragmentList {
14659                    fragments,
14660                    contains_boundary: true,
14661                },
14662            )]);
14663            Protocol::new(WebUIProtocol::new(fragments))
14664        }
14665
14666        let handler = WebUIHandler::new();
14667        let mut writer = FlushTestWriter::default();
14668        let result = handler.render_streaming(
14669            &protocol(vec![WebUIFragment::boundary_end(0)]),
14670            &test_json!({}),
14671            &RenderOptions::new("index.html", "/"),
14672            &mut writer,
14673        );
14674        match result {
14675            Err(HandlerError::StreamingBoundary(err)) => {
14676                assert_eq!(err.signal, "boundary");
14677                assert!(err.reason.contains("no active occurrence"));
14678            }
14679            other => panic!("expected ordered-boundary error, got {other:?}"),
14680        }
14681
14682        let mut writer = FlushTestWriter::default();
14683        let result = handler.render_streaming(
14684            &protocol(vec![
14685                WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14686                WebUIFragment::boundary(1, "index.html", "boundary-1", None),
14687            ]),
14688            &test_json!({}),
14689            &RenderOptions::new("index.html", "/"),
14690            &mut writer,
14691        );
14692        assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
14693    }
14694
14695    #[test]
14696    fn streaming_render_propagates_boundary_flush_disconnect() {
14697        let protocol = streaming_protocol(true);
14698        let handler = WebUIHandler::new();
14699        let mut writer = FlushTestWriter {
14700            fail_flush: true,
14701            ..FlushTestWriter::default()
14702        };
14703        let result = handler.render_streaming(
14704            &protocol,
14705            &test_json!({}),
14706            &RenderOptions::new("index.html", "/"),
14707            &mut writer,
14708        );
14709        assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
14710        assert!(!writer.ended);
14711    }
14712
14713    #[test]
14714    fn streaming_render_requires_structural_head_start_before_writing() {
14715        let fragments = HashMap::from([(
14716            "index.html".to_string(),
14717            FragmentList {
14718                fragments: vec![
14719                    WebUIFragment::raw(
14720                        "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
14721                    ),
14722                    structural_fragment("head_end"),
14723                    WebUIFragment::raw("</HEAD><body>"),
14724                    structural_fragment("body_start"),
14725                    structural_fragment("body_end"),
14726                ],
14727                contains_boundary: false,
14728            },
14729        )]);
14730        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14731        let mut writer = FlushTestWriter::default();
14732
14733        let result = WebUIHandler::new().render_streaming(
14734            &protocol,
14735            &test_json!({}),
14736            &RenderOptions::new("index.html", "/"),
14737            &mut writer,
14738        );
14739
14740        assert!(matches!(
14741            result,
14742            Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
14743        ));
14744        assert_eq!(
14745            writer.output,
14746            "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>"
14747        );
14748        assert!(writer.flushes.is_empty());
14749        assert!(!writer.ended);
14750    }
14751
14752    #[test]
14753    fn streaming_render_rejects_duplicate_head_start() {
14754        let fragments = HashMap::from([(
14755            "index.html".to_string(),
14756            FragmentList {
14757                fragments: vec![
14758                    WebUIFragment::raw("<html><head>"),
14759                    structural_fragment("head_start"),
14760                    structural_fragment("head_start"),
14761                    structural_fragment("head_end"),
14762                    WebUIFragment::raw("</head><body>"),
14763                    structural_fragment("body_start"),
14764                    structural_fragment("body_end"),
14765                ],
14766                contains_boundary: false,
14767            },
14768        )]);
14769        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14770        let mut writer = FlushTestWriter::default();
14771
14772        let result = WebUIHandler::new().render_streaming(
14773            &protocol,
14774            &test_json!({}),
14775            &RenderOptions::new("index.html", "/"),
14776            &mut writer,
14777        );
14778
14779        assert!(matches!(
14780            result,
14781            Err(HandlerError::DuplicateStreamingHeadStart)
14782        ));
14783        assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
14784        assert!(writer.flushes.is_empty());
14785    }
14786
14787    #[test]
14788    fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
14789        let handler = WebUIHandler::with_plugin(|| {
14790            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14791        });
14792        let state = test_json!({
14793            "count": 1,
14794            "head_start": "must not render",
14795            "boundary_start:0": "must not render",
14796            "boundary_end:0": "must not render",
14797        });
14798        let mut with_boundaries = TestWriter::new();
14799        let mut without_boundaries = TestWriter::new();
14800        handler
14801            .render(
14802                &streaming_protocol(true),
14803                &state,
14804                &RenderOptions::new("index.html", "/"),
14805                &mut with_boundaries,
14806            )
14807            .unwrap();
14808        handler
14809            .render(
14810                &streaming_protocol(false),
14811                &state,
14812                &RenderOptions::new("index.html", "/"),
14813                &mut without_boundaries,
14814            )
14815            .unwrap();
14816        assert_eq!(
14817            with_boundaries.get_content(),
14818            without_boundaries.get_content()
14819        );
14820    }
14821
14822    #[test]
14823    fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
14824        for source in [
14825            r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
14826            r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
14827        ] {
14828            let mut parser = HtmlParser::new();
14829            parser
14830                .parse("index.html", source)
14831                .expect("parse head fixture");
14832            let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14833            let mut writer = TestWriter::new();
14834            WebUIHandler::new()
14835                .render(
14836                    &protocol,
14837                    &test_json!({ "theme": "light" }),
14838                    &RenderOptions::new("index.html", "/"),
14839                    &mut writer,
14840                )
14841                .unwrap();
14842
14843            assert_eq!(
14844                writer.get_content(),
14845                "<html><head><title>T</title></head><body>x</body></html>"
14846            );
14847        }
14848    }
14849
14850    #[test]
14851    fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
14852        let source =
14853            r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
14854        let mut parser = HtmlParser::new();
14855        parser.parse("index.html", source).expect("parse fixture");
14856        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14857
14858        let mut ordinary = TestWriter::new();
14859        WebUIHandler::new()
14860            .render(
14861                &protocol,
14862                &test_json!({}),
14863                &RenderOptions::new("index.html", "/"),
14864                &mut ordinary,
14865            )
14866            .unwrap();
14867        assert_eq!(ordinary.get_content(), source);
14868
14869        let mut streaming = FlushTestWriter::default();
14870        WebUIHandler::new()
14871            .render_streaming(
14872                &protocol,
14873                &test_json!({}),
14874                &RenderOptions::new("index.html", "/"),
14875                &mut streaming,
14876            )
14877            .unwrap();
14878        assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
14879        assert!(streaming.output.contains("</HEAD><BODY>x"));
14880        let opening = streaming.output.find("<HEAD").expect("mixed-case head");
14881        let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
14882        let title = streaming.output.find("<title>").expect("title");
14883        assert!(opening < marker && marker < title);
14884        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14885    }
14886
14887    /// Only `}}}webui:`-namespaced signals are compiler-owned, so an
14888    /// unprefixed `body_end` is ordinary authored content.
14889    #[test]
14890    fn unnamespaced_signal_is_ordinary_content() {
14891        let fragments = HashMap::from([(
14892            "index.html".to_string(),
14893            FragmentList {
14894                fragments: vec![
14895                    WebUIFragment::raw("<html><head>"),
14896                    structural_fragment("head_end"),
14897                    WebUIFragment::raw("</head><body>"),
14898                    WebUIFragment::signal("body_end".to_string(), false),
14899                    structural_fragment("body_end"),
14900                    WebUIFragment::raw("</body></html>"),
14901                ],
14902                contains_boundary: false,
14903            },
14904        )]);
14905        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14906        let mut writer = TestWriter::new();
14907        WebUIHandler::new()
14908            .render(
14909                &protocol,
14910                &test_json!({ "body_end": "content" }),
14911                &RenderOptions::new("index.html", "/"),
14912                &mut writer,
14913            )
14914            .expect("current protocol must render");
14915        assert!(writer.get_content().contains("content"));
14916    }
14917
14918    #[test]
14919    fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
14920        let source = concat!(
14921            "<html><head></head><body>",
14922            "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
14923            "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
14924            "</body></html>",
14925        );
14926        let state = test_json!({
14927            "head_start": "hs",
14928            "head_end": "he",
14929            "body_start": "bs",
14930            "body_end": "be",
14931            "boundary_start:0": "b0s",
14932            "boundary_end:0": "b0e",
14933            "streaming_root:forged": "root",
14934        });
14935        let mut parser = HtmlParser::new();
14936        parser.parse("index.html", source).expect("parse fixture");
14937        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14938        let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
14939
14940        let mut ordinary = TestWriter::new();
14941        WebUIHandler::new()
14942            .render(
14943                &protocol,
14944                &state,
14945                &RenderOptions::new("index.html", "/"),
14946                &mut ordinary,
14947            )
14948            .unwrap();
14949        assert_eq!(ordinary.get_content(), expected);
14950
14951        let mut streaming = FlushTestWriter::default();
14952        WebUIHandler::new()
14953            .render_streaming(
14954                &protocol,
14955                &state,
14956                &RenderOptions::new("index.html", "/"),
14957                &mut streaming,
14958            )
14959            .unwrap();
14960        assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
14961        assert!(!streaming.output.contains("<!--wb:"));
14962        assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
14963        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14964    }
14965
14966    #[test]
14967    fn streaming_rejects_structural_signals_after_terminal() {
14968        let fragments = HashMap::from([(
14969            "index.html".to_string(),
14970            FragmentList {
14971                fragments: vec![
14972                    WebUIFragment::raw("<html><head>"),
14973                    structural_fragment("head_start"),
14974                    structural_fragment("head_end"),
14975                    WebUIFragment::raw("</head><body>"),
14976                    structural_fragment("body_start"),
14977                    structural_fragment("body_end"),
14978                    WebUIFragment::raw("</body>"),
14979                    structural_fragment("boundary_start:0"),
14980                    WebUIFragment::raw("</html>"),
14981                ],
14982                contains_boundary: false,
14983            },
14984        )]);
14985        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14986        let mut streaming = FlushTestWriter::default();
14987        let result = WebUIHandler::new().render_streaming(
14988            &protocol,
14989            &test_json!({}),
14990            &RenderOptions::new("index.html", "/"),
14991            &mut streaming,
14992        );
14993
14994        match result {
14995            Err(HandlerError::StreamingBoundary(error)) => {
14996                assert_eq!(error.signal, "boundary_start:0");
14997                assert!(
14998                    error
14999                        .reason
15000                        .contains("structural signal arrived after body_end"),
15001                    "reason: {}",
15002                    error.reason
15003                );
15004            }
15005            other => panic!("expected post-terminal rejection, got {other:?}"),
15006        }
15007        assert_eq!(
15008            streaming.output,
15009            concat!(
15010                "<html><head><meta name=\"webui-streaming\" content=\"1\"></head><body>",
15011                "<script type=\"application/json\" data-webui-boundary>[0,4,0,{}]</script>",
15012                "<webui-hydrate></webui-hydrate></body>"
15013            )
15014        );
15015        assert!(!streaming.output.contains("<!--wb:0-->"));
15016
15017        let mut ordinary = TestWriter::new();
15018        WebUIHandler::new()
15019            .render(
15020                &protocol,
15021                &test_json!({}),
15022                &RenderOptions::new("index.html", "/"),
15023                &mut ordinary,
15024            )
15025            .unwrap();
15026        assert_eq!(
15027            ordinary.get_content(),
15028            "<html><head></head><body></body></html>"
15029        );
15030    }
15031
15032    fn disjoint_streaming_protocol_ext(
15033        hosts: &[&str],
15034        emit_root_signal: bool,
15035        include_styles: bool,
15036    ) -> Protocol {
15037        let mut fragments = HashMap::new();
15038        let mut entry = vec![
15039            WebUIFragment::raw("<!DOCTYPE html><html><head>"),
15040            structural_fragment("head_start"),
15041            structural_fragment("head_end"),
15042            WebUIFragment::raw("</head><body>"),
15043            structural_fragment("body_start"),
15044        ];
15045        for (sequence, host) in hosts.iter().enumerate() {
15046            entry.push(WebUIFragment::boundary(
15047                sequence as u32,
15048                "index.html",
15049                format!("boundary-{sequence}"),
15050                None,
15051            ));
15052            entry.push(WebUIFragment::raw(format!("<{host}")));
15053            if emit_root_signal {
15054                entry.push(structural_fragment(format!("streaming_root:{host}")));
15055            }
15056            entry.push(WebUIFragment::raw(">"));
15057            entry.push(WebUIFragment::component(*host));
15058            entry.push(WebUIFragment::raw(format!("</{host}>")));
15059            entry.push(WebUIFragment::boundary_end(sequence as u32));
15060        }
15061        entry.push(structural_fragment("body_end"));
15062        entry.push(WebUIFragment::raw("</body></html>"));
15063        fragments.insert(
15064            "index.html".to_string(),
15065            FragmentList {
15066                fragments: entry,
15067                contains_boundary: true,
15068            },
15069        );
15070        fragments.insert(
15071            "comp-a".to_string(),
15072            FragmentList {
15073                fragments: vec![WebUIFragment::raw("<b>A</b>")],
15074                contains_boundary: false,
15075            },
15076        );
15077        fragments.insert(
15078            "comp-b".to_string(),
15079            FragmentList {
15080                fragments: vec![WebUIFragment::raw("<b>B</b>")],
15081                contains_boundary: false,
15082            },
15083        );
15084
15085        let mut document = WebUIProtocol::new(fragments);
15086        document.initial_state_strategy = InitialStateStrategy::Components as i32;
15087        document.components.insert(
15088            "comp-a".to_string(),
15089            webui_protocol::ComponentData {
15090                template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
15091                hydration_mode: StateProjectionMode::Keys as i32,
15092                hydration_keys: vec!["a_count".to_string()],
15093                ..Default::default()
15094            },
15095        );
15096        document.components.insert(
15097            "comp-b".to_string(),
15098            webui_protocol::ComponentData {
15099                template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
15100                hydration_mode: StateProjectionMode::Keys as i32,
15101                hydration_keys: vec!["b_count".to_string()],
15102                ..Default::default()
15103            },
15104        );
15105        if include_styles {
15106            document.set_css_strategy(webui_protocol::CssStrategy::Style);
15107            document
15108                .components
15109                .get_mut("comp-a")
15110                .expect("comp-a component")
15111                .css = ".comp-a{color:red}".to_string();
15112            document
15113                .components
15114                .get_mut("comp-b")
15115                .expect("comp-b component")
15116                .css = ".comp-b{color:blue}".to_string();
15117            document.populate_style_closures(&["index.html"]);
15118        }
15119        Protocol::new(document)
15120    }
15121
15122    fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
15123        disjoint_streaming_protocol_ext(hosts, true, false)
15124    }
15125
15126    fn styled_disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
15127        disjoint_streaming_protocol_ext(hosts, true, true)
15128    }
15129
15130    fn streaming_root_validation_protocol(
15131        mut host_fragments: Vec<WebUIFragment>,
15132        tail: Vec<WebUIFragment>,
15133    ) -> Protocol {
15134        let mut entry = vec![
15135            WebUIFragment::raw("<html><head>"),
15136            structural_fragment("head_start"),
15137            structural_fragment("head_end"),
15138            WebUIFragment::raw("</head><body>"),
15139            structural_fragment("body_start"),
15140            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15141        ];
15142        entry.append(&mut host_fragments);
15143        entry.extend(tail);
15144        let fragments = HashMap::from([
15145            (
15146                "index.html".to_string(),
15147                FragmentList {
15148                    fragments: entry,
15149                    contains_boundary: true,
15150                },
15151            ),
15152            (
15153                "comp-a".to_string(),
15154                FragmentList {
15155                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
15156                    contains_boundary: false,
15157                },
15158            ),
15159            (
15160                "comp-b".to_string(),
15161                FragmentList {
15162                    fragments: vec![WebUIFragment::raw("<b>B</b>")],
15163                    contains_boundary: false,
15164                },
15165            ),
15166        ]);
15167        Protocol::new(WebUIProtocol::new(fragments))
15168    }
15169
15170    fn completed_streaming_tail() -> Vec<WebUIFragment> {
15171        vec![
15172            WebUIFragment::raw("</comp-a>"),
15173            WebUIFragment::boundary_end(0),
15174            structural_fragment("body_end"),
15175            WebUIFragment::raw("</body></html>"),
15176        ]
15177    }
15178
15179    fn assert_streaming_root_error(
15180        protocol: &Protocol,
15181        expected_signal: &str,
15182        expected_reason: &str,
15183    ) {
15184        let mut writer = FlushTestWriter::default();
15185        let result = WebUIHandler::new().render_streaming(
15186            protocol,
15187            &test_json!({}),
15188            &RenderOptions::new("index.html", "/"),
15189            &mut writer,
15190        );
15191        match result {
15192            Err(HandlerError::StreamingBoundary(error)) => {
15193                assert_eq!(error.signal, expected_signal);
15194                assert!(
15195                    error.reason.contains(expected_reason),
15196                    "reason: {}",
15197                    error.reason
15198                );
15199            }
15200            other => panic!("expected streaming-root rejection, got {other:?}"),
15201        }
15202    }
15203
15204    #[test]
15205    fn streaming_root_signal_injects_data_ws_inside_boundary() {
15206        let protocol = disjoint_streaming_protocol(&["comp-a"]);
15207        let handler = WebUIHandler::with_plugin(|| {
15208            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15209        });
15210        let mut writer = FlushTestWriter::default();
15211        handler
15212            .render_streaming(
15213                &protocol,
15214                &test_json!({ "a_count": 1 }),
15215                &RenderOptions::new("index.html", "/"),
15216                &mut writer,
15217            )
15218            .unwrap();
15219        // The parser-owned signal is consumed to inject exactly ` data-ws`
15220        // inside the host's opening tag, before the custom element upgrades.
15221        assert!(
15222            writer.output.contains("<comp-a data-ws>"),
15223            "streamed host must carry data-ws: {}",
15224            writer.output
15225        );
15226    }
15227
15228    #[test]
15229    fn streaming_root_signal_preserves_ordinary_output_bytes() {
15230        // Ordinary rendering ignores `streaming_root` byte-for-byte: identical
15231        // output with and without the signal, and never a `data-ws` attribute.
15232        let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true, false);
15233        let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false, false);
15234        let state = test_json!({ "a_count": 1, "b_count": 2 });
15235        let plugin = || {
15236            WebUIHandler::with_plugin(
15237                || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
15238            )
15239        };
15240        let mut with_writer = TestWriter::new();
15241        let mut without_writer = TestWriter::new();
15242        plugin()
15243            .render(
15244                &with_signal,
15245                &state,
15246                &RenderOptions::new("index.html", "/"),
15247                &mut with_writer,
15248            )
15249            .unwrap();
15250        plugin()
15251            .render(
15252                &without_signal,
15253                &state,
15254                &RenderOptions::new("index.html", "/"),
15255                &mut without_writer,
15256            )
15257            .unwrap();
15258        // The rendered DOM (everything up to the inert data block) is
15259        // deterministic and is where a leaked `data-ws` would appear. The
15260        // ordinary template map is HashSet-ordered, so compare the DOM prefix
15261        // for byte identity rather than the whole document.
15262        let dom_prefix = |content: &str| -> String {
15263            content
15264                .split_once(r#"<script type="application/json" id="webui-data""#)
15265                .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
15266        };
15267        assert_eq!(
15268            dom_prefix(&with_writer.get_content()),
15269            dom_prefix(&without_writer.get_content())
15270        );
15271        assert!(!with_writer.get_content().contains("data-ws"));
15272        assert!(with_writer.get_content().contains("<comp-a>"));
15273    }
15274
15275    #[test]
15276    fn streaming_component_without_root_signal_is_rejected() {
15277        let protocol = streaming_root_validation_protocol(
15278            vec![
15279                WebUIFragment::raw("<comp-a>"),
15280                WebUIFragment::component("comp-a"),
15281            ],
15282            completed_streaming_tail(),
15283        );
15284
15285        assert_streaming_root_error(
15286            &protocol,
15287            "comp-a",
15288            "has no compiler-owned streaming_root signal",
15289        );
15290    }
15291
15292    #[test]
15293    fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
15294        let duplicate = streaming_root_validation_protocol(
15295            vec![
15296                WebUIFragment::raw("<comp-a"),
15297                structural_fragment("streaming_root:comp-a"),
15298                structural_fragment("streaming_root:comp-a"),
15299                WebUIFragment::raw(">"),
15300                WebUIFragment::component("comp-a"),
15301            ],
15302            completed_streaming_tail(),
15303        );
15304        assert_streaming_root_error(&duplicate, "comp-a", "is misplaced");
15305
15306        let after_tag_close = streaming_root_validation_protocol(
15307            vec![
15308                WebUIFragment::raw("<comp-a>"),
15309                structural_fragment("streaming_root:comp-a"),
15310                WebUIFragment::component("comp-a"),
15311            ],
15312            completed_streaming_tail(),
15313        );
15314        assert_streaming_root_error(&after_tag_close, "comp-a", "is misplaced");
15315
15316        let detached_from_opening = streaming_root_validation_protocol(
15317            vec![
15318                WebUIFragment::raw("not an opening tag"),
15319                structural_fragment("streaming_root:comp-a"),
15320                WebUIFragment::raw(">"),
15321                WebUIFragment::component("comp-a"),
15322            ],
15323            completed_streaming_tail(),
15324        );
15325        assert_streaming_root_error(
15326            &detached_from_opening,
15327            "comp-a",
15328            "unclosed component opening-tag close",
15329        );
15330    }
15331
15332    #[test]
15333    fn mismatched_streaming_root_signal_is_rejected() {
15334        let protocol = streaming_root_validation_protocol(
15335            vec![
15336                WebUIFragment::raw("<comp-a"),
15337                structural_fragment("streaming_root:comp-b"),
15338                WebUIFragment::raw(">"),
15339                WebUIFragment::component("comp-a"),
15340            ],
15341            completed_streaming_tail(),
15342        );
15343
15344        assert_streaming_root_error(&protocol, "comp-b", "root signal names <comp-b>");
15345    }
15346
15347    #[test]
15348    fn pending_streaming_root_is_rejected_at_structural_ends() {
15349        let protocol = streaming_root_validation_protocol(
15350            vec![
15351                WebUIFragment::raw("<comp-a"),
15352                structural_fragment("streaming_root:comp-a"),
15353                WebUIFragment::raw(">"),
15354            ],
15355            vec![structural_fragment("boundary_end:0")],
15356        );
15357        assert_streaming_root_error(&protocol, "comp-a", "is misplaced");
15358    }
15359
15360    #[test]
15361    fn streaming_root_outside_boundary_is_rejected() {
15362        let fragments = HashMap::from([
15363            (
15364                "index.html".to_string(),
15365                FragmentList {
15366                    fragments: vec![
15367                        WebUIFragment::raw("<html><head>"),
15368                        structural_fragment("head_start"),
15369                        structural_fragment("head_end"),
15370                        WebUIFragment::raw("</head><body>"),
15371                        structural_fragment("body_start"),
15372                        WebUIFragment::raw("<comp-a"),
15373                        structural_fragment("streaming_root:comp-a"),
15374                        WebUIFragment::raw(">"),
15375                        WebUIFragment::component("comp-a"),
15376                        WebUIFragment::raw("</comp-a>"),
15377                        structural_fragment("body_end"),
15378                        WebUIFragment::raw("</body></html>"),
15379                    ],
15380                    contains_boundary: false,
15381                },
15382            ),
15383            (
15384                "comp-a".to_string(),
15385                FragmentList {
15386                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
15387                    contains_boundary: false,
15388                },
15389            ),
15390        ]);
15391        let protocol = Protocol::new(WebUIProtocol::new(fragments));
15392        let mut writer = FlushTestWriter::default();
15393        let result = WebUIHandler::new().render_streaming(
15394            &protocol,
15395            &test_json!({}),
15396            &RenderOptions::new("index.html", "/"),
15397            &mut writer,
15398        );
15399        match result {
15400            Err(HandlerError::StreamingBoundary(err)) => {
15401                assert_eq!(err.signal, "comp-a");
15402                assert!(
15403                    err.reason
15404                        .contains("no boundary or generated component span"),
15405                    "reason: {}",
15406                    err.reason
15407                );
15408            }
15409            other => panic!("expected outside-boundary rejection, got {other:?}"),
15410        }
15411    }
15412
15413    fn parser_route_protocol(with_boundary: bool) -> Protocol {
15414        let mut parser = HtmlParser::with_options(DomStrategy::Light);
15415        parser
15416            .component_registry_mut()
15417            .register_component(ComponentRegistration::new(
15418                "route-page",
15419                "<p>route content</p>",
15420                None,
15421                true,
15422            ))
15423            .expect("register route component");
15424        let route = r#"<route path="/" component="route-page" exact />"#;
15425        let html = if with_boundary {
15426            format!(
15427                "<html><head></head><body>\
15428                 <boundary name=\"route\">{route}</boundary>\
15429                 </body></html>"
15430            )
15431        } else {
15432            format!("<html><head></head><body>{route}</body></html>")
15433        };
15434        parser
15435            .parse("index.html", &html)
15436            .expect("parse route streaming fixture");
15437        let mut document = WebUIProtocol::new(parser.into_fragment_records());
15438        document.components.insert(
15439            "route-page".to_string(),
15440            webui_protocol::ComponentData::default(),
15441        );
15442        Protocol::new(document)
15443    }
15444
15445    #[test]
15446    fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
15447        let protocol = parser_route_protocol(true);
15448        let mut writer = FlushTestWriter::default();
15449        WebUIHandler::new()
15450            .render_streaming(
15451                &protocol,
15452                &test_json!({}),
15453                &RenderOptions::new("index.html", "/"),
15454                &mut writer,
15455            )
15456            .unwrap();
15457
15458        assert!(
15459            writer
15460                .output
15461                .contains("<route-page data-ws><p>route content</p></route-page>"),
15462            "matched route host must be deferred before upgrade: {}",
15463            writer.output
15464        );
15465    }
15466
15467    #[test]
15468    fn streaming_parser_route_host_outside_boundary_is_rejected() {
15469        let protocol = parser_route_protocol(false);
15470        let mut writer = FlushTestWriter::default();
15471        let result = WebUIHandler::new().render_streaming(
15472            &protocol,
15473            &test_json!({}),
15474            &RenderOptions::new("index.html", "/"),
15475            &mut writer,
15476        );
15477
15478        match result {
15479            Err(HandlerError::StreamingBoundary(error)) => {
15480                assert_eq!(error.signal, "route-page");
15481                assert!(
15482                    error
15483                        .reason
15484                        .contains("no boundary or generated component span"),
15485                    "reason: {}",
15486                    error.reason
15487                );
15488            }
15489            other => panic!("expected route boundary rejection, got {other:?}"),
15490        }
15491        assert!(
15492            !writer.output.contains("<route-page>"),
15493            "an unmarked custom-element opening must never be completed"
15494        );
15495    }
15496
15497    #[test]
15498    fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
15499        let entry = vec![
15500            WebUIFragment::raw("<html><head>"),
15501            structural_fragment("head_start"),
15502            structural_fragment("head_end"),
15503            WebUIFragment::raw("</head><body>"),
15504            structural_fragment("body_start"),
15505            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15506            WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
15507                path: "/account".into(),
15508                fragment_id: "account-shell".into(),
15509                exact: false,
15510                ..Default::default()
15511            }),
15512            WebUIFragment::boundary_end(0),
15513            structural_fragment("body_end"),
15514            WebUIFragment::raw("</body></html>"),
15515        ];
15516        let fragments = HashMap::from([
15517            (
15518                "index.html".to_string(),
15519                FragmentList {
15520                    fragments: entry,
15521                    contains_boundary: true,
15522                },
15523            ),
15524            (
15525                "account-shell".to_string(),
15526                FragmentList {
15527                    fragments: vec![WebUIFragment::route_from(
15528                        webui_protocol::WebUiFragmentRoute {
15529                            path: "./details".into(),
15530                            fragment_id: "details-page".into(),
15531                            exact: true,
15532                            pending_component: "details-loading".into(),
15533                            error_component: "details-error".into(),
15534                            ..Default::default()
15535                        },
15536                    )],
15537                    contains_boundary: false,
15538                },
15539            ),
15540            (
15541                "details-page".to_string(),
15542                FragmentList {
15543                    fragments: vec![WebUIFragment::raw("<p>details</p>")],
15544                    contains_boundary: false,
15545                },
15546            ),
15547            (
15548                "details-loading".to_string(),
15549                FragmentList {
15550                    fragments: vec![WebUIFragment::raw("<p>loading</p>")],
15551                    contains_boundary: false,
15552                },
15553            ),
15554            (
15555                "details-error".to_string(),
15556                FragmentList {
15557                    fragments: vec![WebUIFragment::raw("<p>error</p>")],
15558                    contains_boundary: false,
15559                },
15560            ),
15561        ]);
15562        let mut document = WebUIProtocol::new(fragments);
15563        document.initial_state_strategy = InitialStateStrategy::Components as i32;
15564        for name in [
15565            "account-shell",
15566            "details-page",
15567            "details-loading",
15568            "details-error",
15569        ] {
15570            document.components.insert(
15571                name.to_string(),
15572                webui_protocol::ComponentData {
15573                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
15574                    ..Default::default()
15575                },
15576            );
15577        }
15578        let protocol = Protocol::new(document);
15579        let handler = WebUIHandler::with_plugin(|| {
15580            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15581        });
15582        let mut writer = FlushTestWriter::default();
15583
15584        handler
15585            .render_streaming(
15586                &protocol,
15587                &test_json!({}),
15588                &RenderOptions::new("index.html", "/account/details"),
15589                &mut writer,
15590            )
15591            .unwrap();
15592
15593        let first = &writer.output[writer.flushes[0]..writer.flushes[1]];
15594        assert!(
15595            first.contains(r#""details-loading":"#),
15596            "relative-route pending metadata missing: {first}"
15597        );
15598        assert!(
15599            first.contains(r#""details-error":"#),
15600            "relative-route error metadata missing: {first}"
15601        );
15602    }
15603
15604    #[test]
15605    fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
15606        let entry = vec![
15607            WebUIFragment::raw("<html><head>"),
15608            structural_fragment("head_start"),
15609            structural_fragment("head_end"),
15610            WebUIFragment::raw("</head><body>"),
15611            structural_fragment("body_start"),
15612            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15613            WebUIFragment::raw("<static-shell"),
15614            structural_fragment("streaming_root:static-shell"),
15615            WebUIFragment::raw(">"),
15616            WebUIFragment::component("static-shell"),
15617            WebUIFragment::raw("</static-shell>"),
15618            WebUIFragment::raw("<route-shell"),
15619            structural_fragment("streaming_root:route-shell"),
15620            WebUIFragment::raw(">"),
15621            WebUIFragment::component("route-shell"),
15622            WebUIFragment::raw("</route-shell>"),
15623            WebUIFragment::boundary_end(0),
15624            structural_fragment("body_end"),
15625            WebUIFragment::raw("</body></html>"),
15626        ];
15627        let fragments = HashMap::from([
15628            (
15629                "index.html".to_string(),
15630                FragmentList {
15631                    fragments: entry,
15632                    contains_boundary: true,
15633                },
15634            ),
15635            (
15636                "static-shell".to_string(),
15637                FragmentList {
15638                    fragments: vec![WebUIFragment::if_cond(
15639                        ConditionExpr::identifier("show_hidden"),
15640                        "static-hidden-if",
15641                    )],
15642                    contains_boundary: false,
15643                },
15644            ),
15645            (
15646                "static-hidden-if".to_string(),
15647                FragmentList {
15648                    fragments: vec![WebUIFragment::component("static-hidden")],
15649                    contains_boundary: false,
15650                },
15651            ),
15652            (
15653                "static-hidden".to_string(),
15654                FragmentList {
15655                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15656                    contains_boundary: false,
15657                },
15658            ),
15659            (
15660                "route-shell".to_string(),
15661                FragmentList {
15662                    fragments: vec![WebUIFragment::route_from(
15663                        webui_protocol::WebUiFragmentRoute {
15664                            path: "/account".into(),
15665                            fragment_id: "route-page".into(),
15666                            exact: true,
15667                            ..Default::default()
15668                        },
15669                    )],
15670                    contains_boundary: false,
15671                },
15672            ),
15673            (
15674                "route-page".to_string(),
15675                FragmentList {
15676                    fragments: vec![WebUIFragment::raw("<p>route</p>")],
15677                    contains_boundary: false,
15678                },
15679            ),
15680        ]);
15681        let mut document = WebUIProtocol::new(fragments);
15682        document.set_css_strategy(webui_protocol::CssStrategy::Link);
15683        document.initial_state_strategy = InitialStateStrategy::Components as i32;
15684        for (name, keys) in [
15685            ("static-shell", vec!["show_hidden", "static_count"]),
15686            ("static-hidden", vec!["hidden_count"]),
15687            ("route-shell", vec!["route_shell_count"]),
15688            ("route-page", vec!["route_count"]),
15689        ] {
15690            document.components.insert(
15691                name.to_string(),
15692                webui_protocol::ComponentData {
15693                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
15694                    hydration_mode: StateProjectionMode::Keys as i32,
15695                    hydration_keys: keys.into_iter().map(str::to_string).collect(),
15696                    css_href: format!("/{name}.css"),
15697                    ..Default::default()
15698                },
15699            );
15700        }
15701        document.populate_style_closures(&["index.html"]);
15702        let protocol = Protocol::new(document);
15703        assert_eq!(
15704            protocol
15705                .component_reachability()
15706                .is_route_dependent(protocol.component_index()["route-shell"]),
15707            Some(true),
15708            "fixture must exercise request-aware checkpoint reachability"
15709        );
15710        let handler = WebUIHandler::with_plugin(|| {
15711            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15712        });
15713        let mut writer = FlushTestWriter::default();
15714
15715        handler
15716            .render_streaming(
15717                &protocol,
15718                &test_json!({
15719                    "show_hidden": false,
15720                    "static_count": 1,
15721                    "hidden_count": 2,
15722                    "route_shell_count": 3,
15723                    "route_count": 4,
15724                    "unrelated": "private",
15725                }),
15726                &RenderOptions::new("index.html", "/account"),
15727                &mut writer,
15728            )
15729            .unwrap();
15730
15731        let checkpoint = &writer.output[writer.flushes[0]..writer.flushes[1]];
15732        for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
15733            assert!(
15734                checkpoint.contains(&format!(r#""{template}":"#)),
15735                "{template} metadata missing: {checkpoint}"
15736            );
15737        }
15738        for state in [
15739            r#""show_hidden":false"#,
15740            r#""static_count":1"#,
15741            r#""hidden_count":2"#,
15742            r#""route_shell_count":3"#,
15743            r#""route_count":4"#,
15744        ] {
15745            assert!(
15746                checkpoint.contains(state),
15747                "{state} state missing: {checkpoint}"
15748            );
15749        }
15750        assert!(
15751            !checkpoint.contains("unrelated"),
15752            "checkpoint leaked unrelated state: {checkpoint}"
15753        );
15754        assert!(
15755            checkpoint.contains(
15756                r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
15757            ),
15758            "checkpoint metadata lost source order: {checkpoint}"
15759        );
15760        assert!(
15761            checkpoint.contains(r#""componentStyles":"#)
15762                && checkpoint.contains(r#""strategy":"link""#)
15763                && checkpoint.contains(r#""static-shell":["static-shell","static-hidden"]"#),
15764            "checkpoint is missing tree-local style metadata: {checkpoint}"
15765        );
15766        for resource in ["static-shell", "static-hidden", "route-shell", "route-page"] {
15767            assert_eq!(
15768                writer
15769                    .output
15770                    .matches(&format!(r#"data-webui-resource="{resource}""#))
15771                    .count(),
15772                1,
15773                "Document delivery state must persist across streamed checkpoints"
15774            );
15775        }
15776    }
15777
15778    #[test]
15779    fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
15780        // Three boundaries: comp-a, comp-b, then comp-a reused. Each checkpoint
15781        // envelope must be locally scoped — templates/state for exactly the
15782        // components rendered since the previous checkpoint, with reused tags
15783        // carrying hydration state but never a duplicate template.
15784        let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15785        let handler = WebUIHandler::with_plugin(|| {
15786            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15787        });
15788        let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
15789        let mut writer = FlushTestWriter::default();
15790        handler
15791            .render_streaming(
15792                &protocol,
15793                &state,
15794                &RenderOptions::new("index.html", "/"),
15795                &mut writer,
15796            )
15797            .unwrap();
15798
15799        assert_eq!(
15800            writer.flushes.len(),
15801            5,
15802            "bootstrap, three boundary commits, and one terminal"
15803        );
15804        let segment = |index: usize| -> &str {
15805            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15806        };
15807        let b0 = segment(0);
15808        let b1 = segment(1);
15809        let b2 = segment(2);
15810
15811        // Boundary 0: only comp-a's template/state; no comp-b artifacts.
15812        assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
15813        assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
15814        assert!(
15815            b0.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
15816            "b0: {b0}"
15817        );
15818        assert!(b0.contains(r#""inventory":"01""#), "b0: {b0}");
15819        assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
15820        assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
15821
15822        // Boundary 1: only comp-b's template/state; no duplicate comp-a template.
15823        assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
15824        assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
15825        assert!(
15826            b1.contains(r#"[1,0,1,{"declarationId":1,"componentStyles":"#),
15827            "b1: {b1}"
15828        );
15829        assert!(b1.contains(r#""inventory":"02""#), "b1: {b1}");
15830        assert!(
15831            !b1.contains(r#""templates":{"comp-a"#),
15832            "b1 re-sent comp-a: {b1}"
15833        );
15834        assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
15835
15836        // Boundary 2: comp-a reused — state present, template absent (empty delta).
15837        assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
15838        assert!(
15839            b2.contains(r#"[2,0,2,{"declarationId":2,"componentStyles":"#),
15840            "b2: {b2}"
15841        );
15842        assert!(b2.contains(r#""inventory":"""#), "b2: {b2}");
15843        assert!(
15844            !b2.contains(r#""templates""#),
15845            "b2 re-sent a template: {b2}"
15846        );
15847
15848        // Server-only state never leaks into any envelope.
15849        assert!(!writer.output.contains("serverOnly"));
15850    }
15851
15852    #[test]
15853    fn streaming_checkpoints_emit_style_resources_and_closures_once() {
15854        let protocol = styled_disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15855        let handler = WebUIHandler::with_plugin(|| {
15856            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15857        });
15858        let mut writer = FlushTestWriter::default();
15859        handler
15860            .render_streaming(
15861                &protocol,
15862                &test_json!({ "a_count": 1, "b_count": 2 }),
15863                &RenderOptions::new("index.html", "/"),
15864                &mut writer,
15865            )
15866            .unwrap();
15867
15868        let segment = |index: usize| -> &str {
15869            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15870        };
15871        let checkpoint_styles = |checkpoint: &str| -> Value {
15872            let script = checkpoint
15873                .rfind(r#"<script type="application/json" data-webui-boundary"#)
15874                .unwrap();
15875            let payload_start = script + checkpoint[script..].find('>').unwrap() + 1;
15876            let payload_end =
15877                payload_start + checkpoint[payload_start..].find("</script>").unwrap();
15878            let record: Value =
15879                serde_json::from_str(&checkpoint[payload_start..payload_end]).unwrap();
15880            record[3]["componentStyles"].clone()
15881        };
15882
15883        let first = checkpoint_styles(segment(0));
15884        assert!(first["resources"]["comp-a"].is_object());
15885        assert!(first["resources"]["comp-b"].is_object());
15886        assert!(first["closures"]["index.html"].is_array());
15887        assert!(first["closures"]["comp-a"].is_array());
15888
15889        let second = checkpoint_styles(segment(1));
15890        assert_eq!(second["resources"], test_json!({}));
15891        assert_eq!(second["closures"], test_json!({ "comp-b": ["comp-b"] }));
15892
15893        let repeated = checkpoint_styles(segment(2));
15894        assert_eq!(repeated["resources"], test_json!({}));
15895        assert_eq!(repeated["closures"], test_json!({}));
15896        for resource in ["comp-a", "comp-b"] {
15897            assert_eq!(
15898                [&first, &second, &repeated]
15899                    .iter()
15900                    .filter(|styles| {
15901                        styles["resources"]
15902                            .as_object()
15903                            .is_some_and(|resources| resources.contains_key(resource))
15904                    })
15905                    .count(),
15906                1,
15907                "{resource} style metadata was serialized more than once"
15908            );
15909        }
15910    }
15911
15912    #[test]
15913    fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
15914        let entry = vec![
15915            WebUIFragment::raw("<html><head>"),
15916            structural_fragment("head_start"),
15917            structural_fragment("head_end"),
15918            WebUIFragment::raw("</head><body>"),
15919            structural_fragment("body_start"),
15920            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15921            WebUIFragment::raw("<comp-a"),
15922            structural_fragment("streaming_root:comp-a"),
15923            WebUIFragment::raw(">"),
15924            WebUIFragment::component("comp-a"),
15925            WebUIFragment::raw("</comp-a>"),
15926            WebUIFragment::boundary_end(0),
15927            WebUIFragment::boundary(1, "index.html", "boundary-1", None),
15928            WebUIFragment::raw("<comp-hidden"),
15929            structural_fragment("streaming_root:comp-hidden"),
15930            WebUIFragment::raw(">"),
15931            WebUIFragment::component("comp-hidden"),
15932            WebUIFragment::raw("</comp-hidden>"),
15933            WebUIFragment::boundary_end(1),
15934            structural_fragment("body_end"),
15935            WebUIFragment::raw("</body></html>"),
15936        ];
15937        let fragments = HashMap::from([
15938            (
15939                "index.html".to_string(),
15940                FragmentList {
15941                    fragments: entry,
15942                    contains_boundary: true,
15943                },
15944            ),
15945            (
15946                "comp-a".to_string(),
15947                FragmentList {
15948                    fragments: vec![WebUIFragment::if_cond(
15949                        ConditionExpr::identifier("show_hidden"),
15950                        "hidden-if",
15951                    )],
15952                    contains_boundary: false,
15953                },
15954            ),
15955            (
15956                "hidden-if".to_string(),
15957                FragmentList {
15958                    fragments: vec![WebUIFragment::component("comp-hidden")],
15959                    contains_boundary: false,
15960                },
15961            ),
15962            (
15963                "comp-hidden".to_string(),
15964                FragmentList {
15965                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15966                    contains_boundary: false,
15967                },
15968            ),
15969        ]);
15970        let mut document = WebUIProtocol::new(fragments);
15971        document.set_css_strategy(webui_protocol::CssStrategy::Module);
15972        document.initial_state_strategy = InitialStateStrategy::Components as i32;
15973        document.components.insert(
15974            "comp-a".to_string(),
15975            webui_protocol::ComponentData {
15976                template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
15977                hydration_mode: StateProjectionMode::Keys as i32,
15978                hydration_keys: vec!["show_hidden".to_string()],
15979                ..Default::default()
15980            },
15981        );
15982        document.components.insert(
15983            "comp-hidden".to_string(),
15984            webui_protocol::ComponentData {
15985                template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
15986                css: ".hidden{display:block}".to_string(),
15987                hydration_mode: StateProjectionMode::Keys as i32,
15988                hydration_keys: vec!["hidden_count".to_string()],
15989                ..Default::default()
15990            },
15991        );
15992        document.populate_style_closures(&["index.html"]);
15993        let protocol = Protocol::new(document);
15994        let handler = WebUIHandler::with_plugin(|| {
15995            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15996        });
15997        let mut writer = FlushTestWriter::default();
15998
15999        handler
16000            .render_streaming(
16001                &protocol,
16002                &test_json!({
16003                    "show_hidden": false,
16004                    "hidden_count": 7,
16005                    "unrelated": "private",
16006                }),
16007                &RenderOptions::new("index.html", "/"),
16008                &mut writer,
16009            )
16010            .unwrap();
16011
16012        let boundary = |index: usize| -> &str {
16013            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
16014        };
16015        let first = boundary(0);
16016        let second = boundary(1);
16017
16018        assert!(first.contains(r#""comp-a":"#), "first: {first}");
16019        assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
16020        assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
16021        assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
16022        assert!(first.contains(r#""inventory":"01""#), "first: {first}");
16023        assert!(
16024            first.contains(r#"<script type="importmap""#),
16025            "first: {first}"
16026        );
16027        assert!(
16028            !first.contains("unrelated"),
16029            "first leaked unrelated state: {first}"
16030        );
16031
16032        assert!(
16033            !second.contains(r#""templates""#),
16034            "second re-sent metadata: {second}"
16035        );
16036        assert!(
16037            !second.contains(r#"<script type="importmap""#),
16038            "second re-sent CSS: {second}"
16039        );
16040        assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
16041        assert!(second.contains(r#""inventory":"02""#), "second: {second}");
16042        assert_eq!(
16043            writer
16044                .output
16045                .matches(r#"<script type="importmap">"#)
16046                .count(),
16047            1,
16048            "import-map output: {}",
16049            writer.output
16050        );
16051    }
16052
16053    #[test]
16054    fn streaming_flush_count_is_boundaries_plus_one() {
16055        for &count in &[1usize, 3, 10, 100] {
16056            let hosts: Vec<&str> = (0..count)
16057                .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
16058                .collect();
16059            let protocol = disjoint_streaming_protocol(&hosts);
16060            let handler = WebUIHandler::with_plugin(|| {
16061                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
16062            });
16063            let mut writer = FlushTestWriter::default();
16064            handler
16065                .render_streaming(
16066                    &protocol,
16067                    &test_json!({ "a_count": 1, "b_count": 2 }),
16068                    &RenderOptions::new("index.html", "/"),
16069                    &mut writer,
16070                )
16071                .unwrap();
16072            assert_eq!(
16073                writer.flushes.len(),
16074                count + 2,
16075                "expected bootstrap + boundaries + terminal flushes for {count} boundaries"
16076            );
16077        }
16078    }
16079
16080    #[test]
16081    fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
16082        let mut fragments = HashMap::new();
16083        fragments.insert("comp-a".to_string(), FragmentList::default());
16084        fragments.insert("comp-b".to_string(), FragmentList::default());
16085        let mut protocol = WebUIProtocol::new(fragments);
16086        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
16087        protocol.components.insert(
16088            "comp-a".to_string(),
16089            webui_protocol::ComponentData {
16090                hydration_mode: StateProjectionMode::Keys as i32,
16091                hydration_keys: vec!["a".to_string(), "shared".to_string()],
16092                ..Default::default()
16093            },
16094        );
16095        protocol.components.insert(
16096            "comp-b".to_string(),
16097            webui_protocol::ComponentData {
16098                hydration_mode: StateProjectionMode::Keys as i32,
16099                hydration_keys: vec!["b".to_string(), "shared".to_string()],
16100                ..Default::default()
16101            },
16102        );
16103
16104        let protocol = Protocol::new(protocol);
16105        let component_index = protocol.component_index();
16106        let reachability = protocol.component_reachability();
16107        let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
16108        assert!(!collect_hydration_key_ids_into(
16109            protocol.protocol(),
16110            reachability,
16111            [component_index["comp-a"]],
16112            &mut scratch,
16113        ));
16114        let keys = scratch
16115            .iter()
16116            .filter_map(|id| reachability.hydration_key(*id))
16117            .collect::<Vec<_>>();
16118        assert_eq!(keys, ["a", "shared"]);
16119        let pointer = scratch.as_ptr();
16120        let capacity = scratch.capacity();
16121
16122        assert!(!collect_hydration_key_ids_into(
16123            protocol.protocol(),
16124            reachability,
16125            [component_index["comp-b"]],
16126            &mut scratch,
16127        ));
16128        let keys = scratch
16129            .iter()
16130            .filter_map(|id| reachability.hydration_key(*id))
16131            .collect::<Vec<_>>();
16132        assert_eq!(keys, ["b", "shared"]);
16133        assert_eq!(scratch.as_ptr(), pointer);
16134        assert_eq!(scratch.capacity(), capacity);
16135    }
16136}