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                // Always emit the attribute so FAST hydration markers
3943                // (`data-fe`) match the DOM node structure.
3944                match value.as_deref() {
3945                    Some(Value::String(s)) => {
3946                        write_attr(
3947                            context.writer,
3948                            &attr.name,
3949                            &crate::html_encode::encode_safe(s),
3950                        )?;
3951                    }
3952                    Some(Value::Null) | None => {
3953                        write_attr(context.writer, &attr.name, "")?;
3954                    }
3955                    Some(other) => {
3956                        let mut scalar = ScalarBuffer::new();
3957                        match format_plain_json_scalar(&mut scalar, other) {
3958                            Some(rendered) => {
3959                                write_attr(context.writer, &attr.name, rendered)?;
3960                            }
3961                            None => {
3962                                let s = other.to_string();
3963                                write_attr(
3964                                    context.writer,
3965                                    &attr.name,
3966                                    &crate::html_encode::encode_safe(&s),
3967                                )?;
3968                            }
3969                        }
3970                    }
3971                }
3972
3973                if context.collecting_component_attrs && !attr.attr_skip {
3974                    if let Some(borrowed) = state_backed_value {
3975                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3976                        remove_owned_component_attr(&mut context.component_attrs, name);
3977                        context.component_borrowed_attrs.insert(name, borrowed);
3978                    } else {
3979                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3980                        context.component_borrowed_attrs.remove(name);
3981                        context.component_attrs.insert(
3982                            name.to_owned(),
3983                            value
3984                                .map(Cow::into_owned)
3985                                .unwrap_or(Value::String(String::new())),
3986                        );
3987                    }
3988                }
3989            }
3990        }
3991
3992        Ok(())
3993    }
3994
3995    /// Render a template attribute's fragments into a raw (unescaped) string.
3996    fn render_template_attr_value(
3997        &self,
3998        template_id: &str,
3999        target: Option<usize>,
4000        context: &WebUIProcessContext,
4001    ) -> Result<String> {
4002        let Some(index) = target.or_else(|| context.render_fragments.index(template_id)) else {
4003            return Err(HandlerError::MissingFragment(template_id.to_string()));
4004        };
4005        let fragments = context
4006            .render_fragments
4007            .list(index)
4008            .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
4009        let mut raw_value = String::with_capacity(template_attr_capacity(fragments.fragments));
4010        for frag in fragments.fragments {
4011            match frag.fragment.as_ref() {
4012                Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
4013                Some(Fragment::Signal(signal)) => {
4014                    if let Some(value) = resolve_value_from_sources(
4015                        &signal.value,
4016                        &context.loop_vars,
4017                        context.visible_loop_scope,
4018                        LocalValueSources {
4019                            owned: &context.local_vars,
4020                            borrowed: &context.local_borrowed_vars,
4021                        },
4022                        context.state,
4023                    ) {
4024                        match value.as_ref() {
4025                            Value::String(s) => raw_value.push_str(s),
4026                            other => {
4027                                let mut scalar = ScalarBuffer::new();
4028                                match format_plain_json_scalar(&mut scalar, other) {
4029                                    Some(rendered) => raw_value.push_str(rendered),
4030                                    None => raw_value.push_str(&other.to_string()),
4031                                }
4032                            }
4033                        }
4034                    }
4035                }
4036                _ => {}
4037            }
4038        }
4039        Ok(raw_value)
4040    }
4041
4042    /// Render the UI based on the protocol and state.
4043    pub fn render<'a>(
4044        &self,
4045        protocol: &'a Protocol,
4046        state: &'a Value,
4047        options: &RenderOptions<'a>,
4048        writer: &'a mut dyn ResponseWriter,
4049    ) -> Result<()> {
4050        protocol.ensure_style_metadata()?;
4051        let document = protocol.protocol();
4052        let render_fragments = protocol.render_fragments().resolve(document);
4053        let Some(entry_index) = render_fragments.index(options.entry_id) else {
4054            return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
4055        };
4056        let entry = render_fragments
4057            .list(entry_index)
4058            .ok_or_else(|| HandlerError::MissingFragment(options.entry_id.to_string()))?;
4059        let entry_owns_css_tree = Self::component_owns_css_tree(options.entry_id, document);
4060        let has_document_head_boundary = !entry_owns_css_tree
4061            && !document.style_closures.is_empty()
4062            && entry.fragments.iter().any(|fragment| {
4063                matches!(
4064                    fragment.fragment.as_ref(),
4065                    Some(Fragment::Signal(signal))
4066                        if structural_signal_value(signal) == Some("head_end")
4067                )
4068            });
4069        let doctype_split = (!entry_owns_css_tree
4070            && !has_document_head_boundary
4071            && !document.style_closures.is_empty())
4072        .then(|| {
4073            let Some(Fragment::Raw(raw)) = entry
4074                .fragments
4075                .first()
4076                .and_then(|fragment| fragment.fragment.as_ref())
4077            else {
4078                return None;
4079            };
4080            doctype_prefix_end(&raw.value).map(|end| (raw.value.as_str(), end))
4081        })
4082        .flatten();
4083        let component_asset_style_manifest = protocol.component_asset_style_manifest()?;
4084        let mut context = WebUIProcessContext {
4085            protocol: document,
4086            render_fragments,
4087            component_asset_style_manifest,
4088            component_asset_style_links: protocol.component_asset_style_links(),
4089            state,
4090            writer,
4091            local_vars: ScopeMap::default(),
4092            local_borrowed_vars: BorrowedScope::default(),
4093            loop_vars: Vec::new(),
4094            visible_loop_scope: VisibleLoopScope::EMPTY,
4095            component_attrs: ScopeMap::default(),
4096            component_borrowed_attrs: BorrowedScope::default(),
4097            collecting_component_attrs: false,
4098            request_path: options.request_path,
4099            route_base: Cow::Borrowed("/"),
4100            rendered_components: HashSet::new(),
4101            plugin: self.plugin_factory.map(|f| f()),
4102            route_children: Cow::Borrowed(&[]),
4103            entry_id: options.entry_id,
4104            // Same defensive normalisation as `handle()`. See the
4105            // doc-comment there for the CSP-outage rationale.
4106            nonce: options.nonce.filter(|s| !s.is_empty()),
4107            head_inject: options.head_inject.filter(|s| !s.is_empty()),
4108            body_inject: options.body_inject.filter(|s| !s.is_empty()),
4109            state_inject: StateInject::resolve(state),
4110            head_end_emitted: false,
4111            body_start_emitted: false,
4112            component_asset_styles_emitted: false,
4113            component_index: protocol.component_index(),
4114            style_resource_index: protocol.style_resource_index(),
4115            style_resources_requiring_escape: protocol.style_resources_requiring_escape(),
4116            style_chunk_index: protocol.protocol().style_chunk_index(),
4117            css_strategy: protocol.css_strategy(),
4118            body_end_emitted: false,
4119            route_index: protocol.route_index(),
4120            route_chain_index: 0,
4121            route_chain: None,
4122            route_document_style_targets: Vec::new(),
4123            reachable_components: None,
4124            streaming: None,
4125            json_scratch: Vec::new(),
4126            scope_pool: Vec::new(),
4127            document_style_resources: HashSet::new(),
4128            shadow_style_roots: Vec::new(),
4129            borrowed_scope_pool: Vec::new(),
4130        };
4131
4132        if entry_owns_css_tree {
4133            Self::push_shadow_style_root(options.entry_id, &mut context)?;
4134        }
4135
4136        let render_result = if let Some((first_raw, split)) = doctype_split {
4137            context.writer.write(&first_raw[..split])?;
4138            self.emit_component_style_closure(
4139                options.entry_id,
4140                StyleClosureInstall::Static,
4141                &mut context,
4142            )?;
4143            self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
4144            context.writer.write(&first_raw[split..])?;
4145            self.process_fragment_from(entry, 1, &mut context)
4146        } else {
4147            if !entry_owns_css_tree
4148                && !has_document_head_boundary
4149                && !document.style_closures.is_empty()
4150            {
4151                self.emit_component_style_closure(
4152                    options.entry_id,
4153                    StyleClosureInstall::Static,
4154                    &mut context,
4155                )?;
4156                self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
4157            }
4158            self.process_fragment(entry, &mut context)
4159        };
4160
4161        if entry_owns_css_tree {
4162            Self::pop_shadow_style_root(options.entry_id, &mut context)?;
4163        }
4164        render_result?;
4165        writer.end()?;
4166
4167        Ok(())
4168    }
4169}
4170
4171impl Default for WebUIHandler {
4172    fn default() -> Self {
4173        Self::new()
4174    }
4175}
4176
4177/// Bytes reserved for each signal when pre-sizing a template attribute value.
4178/// Signals in attribute templates are typically ids, numbers, or path segments.
4179const TEMPLATE_SIGNAL_CAPACITY_HINT: usize = 32;
4180
4181/// Starting capacity for a rendered template attribute value: the exact static
4182/// text plus a per-signal estimate, so typical values fill one allocation
4183/// instead of regrowing on every append.
4184fn template_attr_capacity(fragments: &[WebUIFragment]) -> usize {
4185    fragments
4186        .iter()
4187        .map(|frag| match frag.fragment.as_ref() {
4188            Some(Fragment::Raw(raw)) => raw.value.len(),
4189            Some(Fragment::Signal(_)) => TEMPLATE_SIGNAL_CAPACITY_HINT,
4190            _ => 0,
4191        })
4192        .sum()
4193}
4194
4195/// Write ` name="value"` to the writer without allocating a format string.
4196fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
4197    writer.write_attribute(name, value)
4198}
4199
4200#[cfg(test)]
4201fn handle(
4202    protocol: &WebUIProtocol,
4203    state: &Value,
4204    options: &RenderOptions<'_>,
4205    writer: &mut dyn ResponseWriter,
4206) -> Result<()> {
4207    let handler = WebUIHandler::new();
4208    handler.handle(protocol, state, options, writer)
4209}
4210
4211#[cfg(test)]
4212mod scalar_buffer_tests {
4213    use super::*;
4214
4215    /// Every scalar the stack formatter claims must serialize byte-identically
4216    /// to the `Value::to_string` path it replaced.
4217    #[test]
4218    fn plain_scalars_match_value_to_string() {
4219        let cases = [
4220            Value::Null,
4221            Value::Bool(true),
4222            Value::Bool(false),
4223            serde_json::json!(0),
4224            serde_json::json!(-1),
4225            serde_json::json!(42),
4226            serde_json::json!(i64::MIN),
4227            serde_json::json!(i64::MAX),
4228            serde_json::json!(u64::MAX),
4229            serde_json::json!(0.5),
4230            serde_json::json!(-0.0_f64),
4231            serde_json::json!(1.7976931348623157e308_f64),
4232            serde_json::json!(-1.7976931348623157e308_f64),
4233            serde_json::json!(f64::MIN_POSITIVE),
4234        ];
4235        for value in cases {
4236            let mut buffer = ScalarBuffer::new();
4237            let rendered = format_plain_json_scalar(&mut buffer, &value);
4238            assert_eq!(
4239                rendered,
4240                Some(value.to_string().as_str()),
4241                "scalar rendering diverged for {value:?}"
4242            );
4243        }
4244    }
4245
4246    /// Strings, arrays, and objects must fall through to the caller's escaping
4247    /// fallback rather than being emitted unescaped.
4248    #[test]
4249    fn non_plain_values_are_declined() {
4250        let cases = [
4251            serde_json::json!("<script>"),
4252            serde_json::json!([1, 2]),
4253            serde_json::json!({ "a": 1 }),
4254        ];
4255        for value in cases {
4256            let mut buffer = ScalarBuffer::new();
4257            assert_eq!(
4258                format_plain_json_scalar(&mut buffer, &value),
4259                None,
4260                "expected fallback for {value:?}"
4261            );
4262        }
4263    }
4264
4265    /// Overflow must be reported, never silently truncated into bad output.
4266    #[test]
4267    fn overflow_is_reported_instead_of_truncating() {
4268        let mut buffer = ScalarBuffer::new();
4269        assert!(write!(&mut buffer, "{}", "x".repeat(49)).is_err());
4270    }
4271}
4272
4273#[cfg(test)]
4274mod tests {
4275    use super::*;
4276    use crate::streaming::STREAMING_MARKER;
4277    use std::cell::RefCell;
4278    use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
4279    use webui_protocol::{
4280        web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
4281        WebUIFragmentAttribute, WebUiFragmentRoute,
4282    };
4283    use webui_test_utils::test_json;
4284
4285    fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
4286        WebUIFragment::signal(
4287            format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
4288            true,
4289        )
4290    }
4291
4292    #[test]
4293    fn route_descent_borrows_protocol_children() {
4294        let routes = vec![WebUiFragmentRoute {
4295            children: vec![WebUiFragmentRoute {
4296                fragment_id: "child".to_string(),
4297                ..Default::default()
4298            }],
4299            ..Default::default()
4300        }];
4301        let mut level = RouteChildren::Borrowed(&routes);
4302
4303        let descended = descend_into(&mut level, 0);
4304
4305        let Cow::Borrowed(children) = descended else {
4306            panic!("protocol-owned route children should remain borrowed");
4307        };
4308        assert!(std::ptr::eq(children, routes[0].children.as_slice()));
4309    }
4310
4311    #[test]
4312    fn route_descent_moves_parked_children() {
4313        let mut level = RouteChildren::Owned(vec![WebUiFragmentRoute {
4314            children: vec![WebUiFragmentRoute {
4315                fragment_id: "child".to_string(),
4316                ..Default::default()
4317            }],
4318            ..Default::default()
4319        }]);
4320
4321        let descended = descend_into(&mut level, 0);
4322
4323        let Cow::Owned(children) = descended else {
4324            panic!("parked route children must not borrow session-owned storage");
4325        };
4326        assert_eq!(children[0].fragment_id, "child");
4327        let Cow::Owned(source) = level else {
4328            panic!("the parked source level must stay owned");
4329        };
4330        assert!(
4331            source[0].children.is_empty(),
4332            "owned route children must move out instead of being cloned"
4333        );
4334    }
4335
4336    #[test]
4337    fn borrowed_collection_reuses_state_array() {
4338        let state = test_json!({
4339            "items": [
4340                {"name": "first"},
4341                {"name": "second"}
4342            ]
4343        });
4344        let local_vars = ScopeMap::default();
4345        let local_borrowed_vars = BorrowedScope::default();
4346        let loop_vars = Vec::new();
4347        let items = resolve_borrowed_collection(
4348            "items",
4349            &loop_vars,
4350            VisibleLoopScope::EMPTY,
4351            LocalValueSources {
4352                owned: &local_vars,
4353                borrowed: &local_borrowed_vars,
4354            },
4355            &state,
4356        )
4357        .unwrap_or_else(|| panic!("state array should be borrowed"));
4358        let state_items = state["items"]
4359            .as_array()
4360            .unwrap_or_else(|| panic!("fixture items should be an array"));
4361        assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4362    }
4363
4364    #[test]
4365    fn owned_local_collection_keeps_precedence() {
4366        let state = test_json!({"items": [{"name": "global"}]});
4367        let local_vars =
4368            ScopeMap::from_iter([("items".to_string(), test_json!([{"name": "local"}]))]);
4369        let local_borrowed_vars = BorrowedScope::default();
4370        assert!(resolve_borrowed_collection(
4371            "items",
4372            &[],
4373            VisibleLoopScope::EMPTY,
4374            LocalValueSources {
4375                owned: &local_vars,
4376                borrowed: &local_borrowed_vars,
4377            },
4378            &state,
4379        )
4380        .is_none());
4381    }
4382
4383    #[test]
4384    fn borrowed_component_collection_reuses_state_array() {
4385        let state = test_json!({"teams": [{"contacts": [{"name": "Avery"}]}]});
4386        let contacts = &state["teams"][0]["contacts"];
4387        let mut local_borrowed_vars = BorrowedScope::default();
4388        local_borrowed_vars.insert("contacts", contacts);
4389        let local_vars = ScopeMap::default();
4390        let items = resolve_borrowed_collection(
4391            "contacts",
4392            &[],
4393            VisibleLoopScope::EMPTY,
4394            LocalValueSources {
4395                owned: &local_vars,
4396                borrowed: &local_borrowed_vars,
4397            },
4398            &state,
4399        )
4400        .unwrap_or_else(|| panic!("component collection should stay borrowed"));
4401        let state_items = contacts
4402            .as_array()
4403            .unwrap_or_else(|| panic!("fixture contacts should be an array"));
4404        assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4405    }
4406
4407    #[test]
4408    fn nested_borrowed_collection_reuses_child_array() {
4409        let state = test_json!({
4410            "items": [{
4411                "children": [
4412                    {"name": "first"},
4413                    {"name": "second"}
4414                ]
4415            }]
4416        });
4417        let item = &state["items"][0];
4418        let loop_vars = vec![LoopBinding {
4419            name: "item",
4420            value: item,
4421        }];
4422        let local_vars = ScopeMap::default();
4423        let local_borrowed_vars = BorrowedScope::default();
4424        let children = resolve_borrowed_collection(
4425            "item.children",
4426            &loop_vars,
4427            VisibleLoopScope { start: 0, end: 1 },
4428            LocalValueSources {
4429                owned: &local_vars,
4430                borrowed: &local_borrowed_vars,
4431            },
4432            &state,
4433        )
4434        .unwrap_or_else(|| panic!("nested state array should be borrowed"));
4435        let state_children = item["children"]
4436            .as_array()
4437            .unwrap_or_else(|| panic!("fixture children should be an array"));
4438        assert!(std::ptr::eq(children.as_ptr(), state_children.as_ptr()));
4439    }
4440
4441    #[test]
4442    fn borrowed_resolver_preserves_loop_precedence_and_component_isolation() {
4443        let state = test_json!({
4444            "item": {"name": "global", "fallback": "global fallback"},
4445            "outer": {"name": "outer"},
4446            "inner": {"name": "inner"}
4447        });
4448        let loop_vars = vec![
4449            LoopBinding {
4450                name: "item",
4451                value: &state["outer"],
4452            },
4453            LoopBinding {
4454                name: "item",
4455                value: &state["inner"],
4456            },
4457        ];
4458        let local_vars = ScopeMap::default();
4459        let local_borrowed_vars = BorrowedScope::default();
4460        let sources = LocalValueSources {
4461            owned: &local_vars,
4462            borrowed: &local_borrowed_vars,
4463        };
4464
4465        assert_eq!(
4466            resolve_value_from_sources(
4467                "item.name",
4468                &loop_vars,
4469                VisibleLoopScope { start: 0, end: 2 },
4470                sources,
4471                &state,
4472            )
4473            .as_deref()
4474            .and_then(Value::as_str),
4475            Some("inner")
4476        );
4477        assert_eq!(
4478            resolve_value_from_sources(
4479                "item.fallback",
4480                &loop_vars,
4481                VisibleLoopScope { start: 0, end: 2 },
4482                sources,
4483                &state,
4484            )
4485            .as_deref()
4486            .and_then(Value::as_str),
4487            Some("global fallback")
4488        );
4489        assert_eq!(
4490            resolve_value_from_sources(
4491                "item.name",
4492                &loop_vars,
4493                VisibleLoopScope { start: 2, end: 2 },
4494                sources,
4495                &state,
4496            )
4497            .as_deref()
4498            .and_then(Value::as_str),
4499            Some("global")
4500        );
4501    }
4502
4503    #[test]
4504    fn state_backed_resolver_matches_general_borrowed_resolution() {
4505        let state = test_json!({
4506            "global": {"name": "global"},
4507            "inner": {"fallback": "global fallback"},
4508            "borrowed": {"name": "borrowed"},
4509            "items": [1, 2, 3]
4510        });
4511        let loop_values = test_json!({
4512            "outer": {"name": "outer"},
4513            "inner": {"name": "inner"}
4514        });
4515        let loop_vars = vec![
4516            LoopBinding {
4517                name: "outer",
4518                value: &loop_values["outer"],
4519            },
4520            LoopBinding {
4521                name: "inner",
4522                value: &loop_values["inner"],
4523            },
4524        ];
4525        let mut local_borrowed_vars = BorrowedScope::default();
4526        local_borrowed_vars.insert("borrowed", &state["borrowed"]);
4527        let local_vars =
4528            ScopeMap::from_iter([("owned".to_string(), test_json!({"name": "owned"}))]);
4529        let sources = LocalValueSources {
4530            owned: &local_vars,
4531            borrowed: &local_borrowed_vars,
4532        };
4533        let scope = VisibleLoopScope { start: 0, end: 2 };
4534
4535        for path in [
4536            "global.name",
4537            "inner.name",
4538            "inner.fallback",
4539            "outer.name",
4540            "borrowed.name",
4541        ] {
4542            let state_backed = resolve_state_backed_value(path, &loop_vars, scope, sources, &state)
4543                .unwrap_or_else(|| panic!("{path} should resolve from immutable state"));
4544            let resolved = resolve_value_from_sources(path, &loop_vars, scope, sources, &state)
4545                .unwrap_or_else(|| panic!("{path} should resolve generally"));
4546            let Cow::Borrowed(resolved) = resolved else {
4547                panic!("{path} should remain borrowed");
4548            };
4549            assert!(
4550                std::ptr::eq(state_backed, resolved),
4551                "{path} resolved from different sources"
4552            );
4553        }
4554
4555        assert!(
4556            resolve_state_backed_value("owned.name", &loop_vars, scope, sources, &state).is_none(),
4557            "owned local values must use the general resolver"
4558        );
4559        assert!(
4560            resolve_state_backed_value("items.length", &loop_vars, scope, sources, &state)
4561                .is_none(),
4562            "synthetic values must use the general resolver"
4563        );
4564    }
4565
4566    #[test]
4567    fn borrowed_scope_preserves_inline_overflow_and_replacement_semantics() {
4568        let values = [
4569            Value::String("a".to_string()),
4570            Value::String("b".to_string()),
4571            Value::String("c".to_string()),
4572            Value::String("d".to_string()),
4573            Value::String("e".to_string()),
4574            Value::String("f".to_string()),
4575            Value::String("replacement".to_string()),
4576        ];
4577        let mut scope = BorrowedScope::default();
4578        for (index, name) in ["a", "b", "c", "d", "e", "f"].iter().enumerate() {
4579            assert!(scope.insert(name, &values[index]).is_none());
4580        }
4581        assert_eq!(scope.inline_len, INLINE_SCOPE_SLOTS);
4582        assert_eq!(scope.overflow.len(), 2);
4583        assert_eq!(scope.get("a"), Some(&values[0]));
4584        assert_eq!(scope.get("f"), Some(&values[5]));
4585        assert_eq!(scope.insert("a", &values[6]), Some(&values[0]));
4586        assert_eq!(scope.get("a"), Some(&values[6]));
4587        assert_eq!(scope.remove("b"), Some(&values[1]));
4588        assert!(scope.get("b").is_none());
4589        assert_eq!(scope.get("f"), Some(&values[5]));
4590        scope.clear();
4591        assert!(scope.get("a").is_none());
4592        assert!(scope.overflow.is_empty());
4593    }
4594
4595    // A simple test writer implementation
4596    struct TestWriter {
4597        content: RefCell<String>,
4598        ended: RefCell<bool>,
4599    }
4600
4601    impl TestWriter {
4602        fn new() -> Self {
4603            Self {
4604                content: RefCell::new(String::new()),
4605                ended: RefCell::new(false),
4606            }
4607        }
4608
4609        fn get_content(&self) -> String {
4610            self.content.borrow().clone()
4611        }
4612
4613        fn is_ended(&self) -> bool {
4614            *self.ended.borrow()
4615        }
4616    }
4617
4618    impl ResponseWriter for TestWriter {
4619        fn write(&mut self, content: &str) -> Result<()> {
4620            self.content.borrow_mut().push_str(content);
4621            Ok(())
4622        }
4623
4624        fn end(&mut self) -> Result<()> {
4625            *self.ended.borrow_mut() = true;
4626            Ok(())
4627        }
4628    }
4629
4630    #[test]
4631    fn generated_string_writer_methods_preserve_exact_attribute_output() {
4632        struct AttributeWriter {
4633            output: String,
4634        }
4635
4636        impl ResponseWriter for AttributeWriter {
4637            fn write(&mut self, content: &str) -> Result<()> {
4638                self.output.push_str(content);
4639                Ok(())
4640            }
4641
4642            crate::string_response_writer_methods!(output);
4643
4644            fn end(&mut self) -> Result<()> {
4645                Ok(())
4646            }
4647        }
4648
4649        let mut writer = AttributeWriter {
4650            output: String::new(),
4651        };
4652        write_attr(&mut writer, "data-id", "42")
4653            .unwrap_or_else(|error| panic!("attribute write failed: {error}"));
4654        writer
4655            .write_boolean_attribute("disabled")
4656            .unwrap_or_else(|error| panic!("boolean attribute write failed: {error}"));
4657        assert_eq!(writer.output, " data-id=\"42\" disabled");
4658    }
4659
4660    #[test]
4661    fn prepared_graph_resolves_targets_and_canonical_component_prop_names() {
4662        let mut fragments = HashMap::new();
4663        fragments.insert(
4664            "index.html".to_string(),
4665            FragmentList {
4666                fragments: vec![
4667                    WebUIFragment {
4668                        fragment: Some(Fragment::Attribute(WebUIFragmentAttribute {
4669                            name: ":data-title".to_string(),
4670                            value: "source".to_string(),
4671                            complex: true,
4672                            attr_start: true,
4673                            ..Default::default()
4674                        })),
4675                    },
4676                    WebUIFragment::component("prepared-child"),
4677                ],
4678                contains_boundary: false,
4679            },
4680        );
4681        fragments.insert(
4682            "prepared-child".to_string(),
4683            FragmentList {
4684                fragments: vec![WebUIFragment::signal("dataTitle", false)],
4685                contains_boundary: false,
4686            },
4687        );
4688        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4689        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4690        let entry = resolved
4691            .list_by_id("index.html")
4692            .unwrap_or_else(|| panic!("entry render plan should exist"));
4693        let child_index = protocol
4694            .render_fragments()
4695            .index("prepared-child")
4696            .unwrap_or_else(|| panic!("component target should be indexed"));
4697        assert_eq!(entry.component_attr_name(0), Some("dataTitle"));
4698        assert_eq!(entry.target(1), Some(child_index));
4699        assert!(!entry.has_routes);
4700        let source_entry = protocol
4701            .protocol()
4702            .fragments
4703            .get("index.html")
4704            .unwrap_or_else(|| panic!("source entry should exist"));
4705        assert!(std::ptr::eq(
4706            entry.fragments.as_ptr(),
4707            source_entry.fragments.as_ptr()
4708        ));
4709
4710        let state = test_json!({"source": "Prepared"});
4711        let options = RenderOptions::new("index.html", "/");
4712        for handler in [
4713            WebUIHandler::new(),
4714            WebUIHandler::with_plugin(|| {
4715                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
4716            }),
4717        ] {
4718            let mut writer = TestWriter::new();
4719            assert!(handler
4720                .render(&protocol, &state, &options, &mut writer)
4721                .is_ok());
4722            assert!(writer.get_content().contains("Prepared"));
4723        }
4724    }
4725
4726    #[test]
4727    fn prepared_graph_records_route_presence_per_fragment_list() {
4728        let mut fragments = HashMap::new();
4729        fragments.insert(
4730            "index.html".to_string(),
4731            FragmentList {
4732                fragments: vec![WebUIFragment::route("/", "dash-page")],
4733                contains_boundary: false,
4734            },
4735        );
4736        fragments.insert(
4737            "dash-page".to_string(),
4738            FragmentList {
4739                fragments: vec![WebUIFragment::raw("dashboard")],
4740                contains_boundary: false,
4741            },
4742        );
4743        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4744        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4745        let entry = resolved
4746            .list_by_id("index.html")
4747            .unwrap_or_else(|| panic!("entry render plan should exist"));
4748        let page = resolved
4749            .list_by_id("dash-page")
4750            .unwrap_or_else(|| panic!("page render plan should exist"));
4751        // Only lists that actually contain a route pay for the sibling scan.
4752        assert!(entry.has_routes);
4753        assert!(!page.has_routes);
4754    }
4755
4756    #[test]
4757    fn prepared_prop_names_match_attribute_to_camel_exactly() {
4758        // Preparation writes the camelCase conversion straight into the shared
4759        // arena instead of calling `attribute_to_camel`, which allocates. The two
4760        // must stay byte-identical, including the irregular lookup-table entries.
4761        let names = [
4762            "data-title",
4763            ":data-title",
4764            "aria-labelledby",
4765            "aria-valuemax",
4766            "readonly",
4767            "tabindex",
4768            "class",
4769            "for",
4770            "maxlength",
4771            "novalidate",
4772            "already-camel",
4773            "trailing-",
4774            "-leading",
4775            "multi-part-attribute-name",
4776            "single",
4777            "",
4778        ];
4779        for name in names {
4780            let stripped = name.strip_prefix(':').unwrap_or(name);
4781            assert_eq!(
4782                component_attr_name(name),
4783                webui_protocol::attrs::attribute_to_camel(stripped),
4784                "prepared name diverged for `{name}`"
4785            );
4786        }
4787    }
4788
4789    #[test]
4790    fn prepared_component_prop_names_match_per_branch_stripping() {
4791        // The render path strips a leading `:` twice on the complex-value branch
4792        // and once elsewhere. Preparation must reproduce that per branch or a
4793        // `::`-prefixed binding would change the emitted component prop name.
4794        let complex = WebUIFragmentAttribute {
4795            name: "::data-title".to_string(),
4796            value: "source".to_string(),
4797            complex: true,
4798            ..Default::default()
4799        };
4800        assert_eq!(component_attr_source(&complex), ":data-title");
4801        assert_eq!(
4802            component_attr_name(component_attr_source(&complex)),
4803            "dataTitle"
4804        );
4805
4806        // `raw_value` takes the static branch, which strips only once.
4807        let raw = WebUIFragmentAttribute {
4808            raw_value: true,
4809            ..complex.clone()
4810        };
4811        assert_eq!(component_attr_source(&raw), "::data-title");
4812
4813        // A template attribute returns before the complex branch is reached.
4814        let templated = WebUIFragmentAttribute {
4815            template: "tpl".to_string(),
4816            ..complex.clone()
4817        };
4818        assert_eq!(component_attr_source(&templated), "::data-title");
4819
4820        // An empty value never reaches the complex branch either.
4821        let empty_value = WebUIFragmentAttribute {
4822            value: String::new(),
4823            ..complex.clone()
4824        };
4825        assert_eq!(component_attr_source(&empty_value), "::data-title");
4826
4827        // Ordinary single-colon bindings are unaffected on every branch.
4828        let ordinary = WebUIFragmentAttribute {
4829            name: ":data-title".to_string(),
4830            ..complex
4831        };
4832        assert_eq!(component_attr_source(&ordinary), "data-title");
4833        assert_eq!(
4834            component_attr_name(component_attr_source(&ordinary)),
4835            "dataTitle"
4836        );
4837    }
4838
4839    #[test]
4840    fn render_slots_match_continuation_slots() {
4841        // The streaming VM reads prepared metadata with the continuation slot
4842        // directly. That is only sound while both numberings index the same
4843        // sorted fragment IDs, so pin the invariant here rather than paying for
4844        // an ID lookup per streamed fragment frame.
4845        let mut fragments = HashMap::new();
4846        for id in [
4847            "index.html",
4848            "zeta-last",
4849            "alpha-first",
4850            "middle-one",
4851            "middle-two",
4852        ] {
4853            fragments.insert(
4854                id.to_string(),
4855                FragmentList {
4856                    fragments: vec![WebUIFragment::raw(id)],
4857                    contains_boundary: false,
4858                },
4859            );
4860        }
4861        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4862        let index = protocol.render_fragments();
4863        for slot in 0..u32::try_from(protocol.protocol().fragments.len())
4864            .unwrap_or_else(|_| panic!("fragment count fits in u32"))
4865        {
4866            let id = protocol
4867                .fragment_id(slot)
4868                .unwrap_or_else(|| panic!("slot {slot} should resolve to an ID"));
4869            assert_eq!(
4870                index.index(id),
4871                Some(slot as usize),
4872                "render slot must equal continuation slot for `{id}`"
4873            );
4874            assert_eq!(protocol.fragment_slot(id), Some(slot));
4875        }
4876    }
4877
4878    #[test]
4879    fn prepared_graph_reports_missing_fragments_without_panicking() {
4880        let mut fragments = HashMap::new();
4881        fragments.insert(
4882            "index.html".to_string(),
4883            FragmentList {
4884                fragments: vec![WebUIFragment::component("absent-child")],
4885                contains_boundary: false,
4886            },
4887        );
4888        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4889        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4890        let entry = resolved
4891            .list_by_id("index.html")
4892            .unwrap_or_else(|| panic!("entry render plan should exist"));
4893        // An unresolvable reference keeps a `None` target and must not index
4894        // out of range or silently render an unrelated fragment list.
4895        assert_eq!(entry.target(0), None);
4896        assert!(resolved.list_by_id("absent-child").is_none());
4897        assert!(resolved.list(usize::MAX).is_none());
4898
4899        let state = test_json!({});
4900        let options = RenderOptions::new("index.html", "/");
4901        let mut writer = TestWriter::new();
4902        match WebUIHandler::new().render(&protocol, &state, &options, &mut writer) {
4903            Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-child"),
4904            other => panic!("expected a missing fragment error, got {other:?}"),
4905        }
4906
4907        let mut writer = TestWriter::new();
4908        match WebUIHandler::new().render(
4909            &protocol,
4910            &state,
4911            &RenderOptions::new("absent-entry", "/"),
4912            &mut writer,
4913        ) {
4914            Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-entry"),
4915            other => panic!("expected a missing entry error, got {other:?}"),
4916        }
4917        // Bindings check the entry before sizing an output buffer, so the
4918        // check must agree with render's own entry lookup.
4919        assert!(protocol.has_fragment("index.html"));
4920        assert!(!protocol.has_fragment("absent-entry"));
4921    }
4922
4923    #[test]
4924    fn prepared_graph_lazily_borrows_large_protocol_fragment_lists() {
4925        let list_count = INLINE_RENDER_FRAGMENT_LISTS * 2 + 1;
4926        let mut fragments = HashMap::with_capacity(list_count);
4927        for index in 0..list_count {
4928            fragments.insert(
4929                format!("fragment-{index}"),
4930                FragmentList {
4931                    fragments: vec![WebUIFragment::raw("content")],
4932                    contains_boundary: false,
4933                },
4934            );
4935        }
4936        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4937        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4938        // Nothing is materialized until a slot is actually rendered.
4939        assert!(resolved.cache[0].get().is_none());
4940        assert!(resolved.spilled.get().is_none());
4941        assert!(resolved.list(0).is_some());
4942        assert!(resolved.cache[0].get().is_some());
4943        // Rendering only inline slots never allocates the spill region.
4944        assert!(resolved.spilled.get().is_none());
4945        // Slots past the inline cache spill once, then stay memoized so a deep
4946        // descent never re-hashes the fragment ID.
4947        assert!(resolved.list(INLINE_RENDER_FRAGMENT_LISTS).is_some());
4948        let spilled = resolved
4949            .spilled
4950            .get()
4951            .expect("a slot past the inline cache should allocate the spill region");
4952        assert_eq!(spilled.len(), list_count - INLINE_RENDER_FRAGMENT_LISTS);
4953        assert!(spilled[0].get().is_some());
4954        assert!(resolved.list(list_count - 1).is_some());
4955
4956        for (id, source) in &protocol.protocol().fragments {
4957            let prepared = resolved
4958                .list_by_id(id)
4959                .unwrap_or_else(|| panic!("render plan should contain {id}"));
4960            // Borrowed, never duplicated: the prepared list points at protocol storage.
4961            assert!(std::ptr::eq(
4962                prepared.fragments.as_ptr(),
4963                source.fragments.as_ptr()
4964            ));
4965        }
4966    }
4967
4968    #[test]
4969    fn test_handle_raw() {
4970        // Create a simple protocol
4971        let mut fragments = HashMap::new();
4972        fragments.insert(
4973            "index.html".to_string(),
4974            FragmentList {
4975                fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
4976                contains_boundary: false,
4977            },
4978        );
4979
4980        let protocol = WebUIProtocol::new(fragments);
4981        let state = test_json!({});
4982
4983        // Create a test writer
4984        let mut writer = TestWriter::new();
4985
4986        // Handle the protocol
4987        assert!(
4988            handle(
4989                &protocol,
4990                &state,
4991                &RenderOptions::new("index.html", "/"),
4992                &mut writer
4993            )
4994            .is_ok(),
4995            "Failed to handle raw protocol"
4996        );
4997
4998        // Check the output
4999        assert_eq!(writer.get_content(), "Hello, WebUI!");
5000        assert!(writer.is_ended());
5001    }
5002
5003    #[test]
5004    fn test_handle_signal() {
5005        // Create a protocol with a signal
5006        let mut fragments = HashMap::new();
5007        fragments.insert(
5008            "index.html".to_string(),
5009            FragmentList {
5010                fragments: vec![
5011                    WebUIFragment::raw("Hello, "),
5012                    WebUIFragment::signal("name", false),
5013                    WebUIFragment::raw("!"),
5014                ],
5015                contains_boundary: false,
5016            },
5017        );
5018
5019        let protocol = WebUIProtocol::new(fragments);
5020        let state = test_json!({"name": "WebUI"});
5021
5022        // Create a test writer
5023        let mut writer = TestWriter::new();
5024
5025        // Handle the protocol
5026        assert!(
5027            handle(
5028                &protocol,
5029                &state,
5030                &RenderOptions::new("index.html", "/"),
5031                &mut writer
5032            )
5033            .is_ok(),
5034            "Failed to handle signal protocol"
5035        );
5036
5037        // Check the output
5038        assert_eq!(writer.get_content(), "Hello, WebUI!");
5039        assert!(writer.is_ended());
5040    }
5041
5042    #[test]
5043    fn test_handle_for_loop() {
5044        // Create a protocol with a for loop
5045        let mut fragments = HashMap::new();
5046        fragments.insert(
5047            "index.html".to_string(),
5048            FragmentList {
5049                fragments: vec![
5050                    WebUIFragment::raw("People: "),
5051                    WebUIFragment::for_loop("person", "people", "person-item"),
5052                ],
5053                contains_boundary: false,
5054            },
5055        );
5056
5057        fragments.insert(
5058            "person-item".to_string(),
5059            FragmentList {
5060                fragments: vec![
5061                    WebUIFragment::signal("person.name", false),
5062                    WebUIFragment::raw(", "),
5063                ],
5064                contains_boundary: false,
5065            },
5066        );
5067
5068        let protocol = WebUIProtocol::new(fragments);
5069        let state = test_json!({
5070            "people": [
5071                {"name": "Alice"},
5072                {"name": "Bob"},
5073                {"name": "Charlie"}
5074            ]
5075        });
5076
5077        // Create a test writer
5078        let mut writer = TestWriter::new();
5079
5080        // Handle the protocol
5081        assert!(
5082            handle(
5083                &protocol,
5084                &state,
5085                &RenderOptions::new("index.html", "/"),
5086                &mut writer
5087            )
5088            .is_ok(),
5089            "Failed to handle for loop protocol"
5090        );
5091
5092        // Check the output
5093        assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
5094        assert!(writer.is_ended());
5095    }
5096
5097    #[test]
5098    fn test_handle_if_condition() {
5099        // Create a protocol with an if condition
5100        let mut fragments = HashMap::new();
5101        fragments.insert(
5102            "index.html".to_string(),
5103            FragmentList {
5104                fragments: vec![
5105                    WebUIFragment::raw("Status: "),
5106                    WebUIFragment::if_cond(
5107                        webui_protocol::ConditionExpr::identifier("isActive"),
5108                        "active-content",
5109                    ),
5110                    WebUIFragment::raw("End"),
5111                ],
5112                contains_boundary: false,
5113            },
5114        );
5115
5116        fragments.insert(
5117            "active-content".to_string(),
5118            FragmentList {
5119                fragments: vec![WebUIFragment::raw("Active")],
5120                contains_boundary: false,
5121            },
5122        );
5123
5124        let protocol = WebUIProtocol::new(fragments);
5125
5126        // Test with isActive = true
5127        let state_true = test_json!({"isActive": true});
5128        let mut writer_true = TestWriter::new();
5129        assert!(
5130            handle(
5131                &protocol,
5132                &state_true,
5133                &RenderOptions::new("index.html", "/"),
5134                &mut writer_true
5135            )
5136            .is_ok(),
5137            "Failed to handle if condition (true case)"
5138        );
5139        assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
5140        assert!(writer_true.is_ended());
5141
5142        // Test with isActive = false
5143        let state_false = test_json!({"isActive": false});
5144        let mut writer_false = TestWriter::new();
5145        assert!(
5146            handle(
5147                &protocol,
5148                &state_false,
5149                &RenderOptions::new("index.html", "/"),
5150                &mut writer_false
5151            )
5152            .is_ok(),
5153            "Failed to handle if condition (false case)"
5154        );
5155        assert_eq!(writer_false.get_content(), "Status: End");
5156        assert!(writer_false.is_ended());
5157    }
5158
5159    #[test]
5160    fn missing_identifier_is_falsy_before_negation_in_global_and_loop_scopes() {
5161        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>"#;
5162        let mut parser = HtmlParser::new();
5163        parser
5164            .parse("index.html", source)
5165            .expect("parse missing-path condition fixture");
5166        let protocol = WebUIProtocol::new(parser.into_fragment_records());
5167        let state = test_json!({
5168            "items": [
5169                {"label": "Normal"},
5170                {"label": "Search", "searchPresentation": true}
5171            ]
5172        });
5173        let mut writer = TestWriter::new();
5174
5175        handle(
5176            &protocol,
5177            &state,
5178            &RenderOptions::new("index.html", "/"),
5179            &mut writer,
5180        )
5181        .expect("render missing-path condition fixture");
5182
5183        assert_eq!(
5184            writer.get_content(),
5185            "top-negated<span>Normal</span><mark>Search</mark>"
5186        );
5187    }
5188
5189    #[test]
5190    fn native_dynamic_attribute_does_not_leak_into_next_component() {
5191        let mut fragments = HashMap::new();
5192        fragments.insert(
5193            "index.html".to_string(),
5194            FragmentList {
5195                fragments: vec![
5196                    WebUIFragment::raw("<div"),
5197                    WebUIFragment {
5198                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5199                            WebUIFragmentAttribute {
5200                                name: "title".into(),
5201                                value: "nativeTitle".into(),
5202                                ..Default::default()
5203                            },
5204                        )),
5205                    },
5206                    WebUIFragment::raw("></div><my-comp>"),
5207                    WebUIFragment::component("my-comp"),
5208                    WebUIFragment::raw("</my-comp>"),
5209                ],
5210                contains_boundary: false,
5211            },
5212        );
5213        fragments.insert(
5214            "my-comp".to_string(),
5215            FragmentList {
5216                fragments: vec![WebUIFragment::signal("title", false)],
5217                contains_boundary: false,
5218            },
5219        );
5220        let protocol = WebUIProtocol::new(fragments);
5221        let state = test_json!({
5222            "nativeTitle": "native",
5223            "title": "global"
5224        });
5225        let mut writer = TestWriter::new();
5226        handle(
5227            &protocol,
5228            &state,
5229            &RenderOptions::new("index.html", "/"),
5230            &mut writer,
5231        )
5232        .unwrap_or_else(|error| panic!("render failed: {error}"));
5233        assert_eq!(
5234            writer.get_content(),
5235            "<div title=\"native\"></div><my-comp>global</my-comp>"
5236        );
5237    }
5238
5239    #[test]
5240    fn test_handle_component() {
5241        // Create a protocol with a component
5242        let mut fragments = HashMap::new();
5243        fragments.insert(
5244            "index.html".to_string(),
5245            FragmentList {
5246                fragments: vec![
5247                    WebUIFragment::raw("Component: "),
5248                    WebUIFragment::component("my-component"),
5249                ],
5250                contains_boundary: false,
5251            },
5252        );
5253
5254        fragments.insert(
5255            "my-component".to_string(),
5256            FragmentList {
5257                fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
5258                contains_boundary: false,
5259            },
5260        );
5261
5262        let protocol = WebUIProtocol::new(fragments);
5263        let state = test_json!({});
5264
5265        // Create a test writer
5266        let mut writer = TestWriter::new();
5267
5268        // Handle the protocol
5269        assert!(
5270            handle(
5271                &protocol,
5272                &state,
5273                &RenderOptions::new("index.html", "/"),
5274                &mut writer
5275            )
5276            .is_ok(),
5277            "Failed to handle component protocol"
5278        );
5279
5280        // Check the output
5281        assert_eq!(
5282            writer.get_content(),
5283            "Component: <div>Component Content</div>"
5284        );
5285        assert!(writer.is_ended());
5286    }
5287
5288    #[test]
5289    fn test_missing_fragment() {
5290        // Create a protocol with a missing fragment reference
5291        let mut fragments = HashMap::new();
5292        fragments.insert(
5293            "index.html".to_string(),
5294            FragmentList {
5295                fragments: vec![WebUIFragment::component("missing-component")],
5296                contains_boundary: false,
5297            },
5298        );
5299
5300        let protocol = WebUIProtocol::new(fragments);
5301        let state = test_json!({});
5302
5303        // Create a test writer
5304        let mut writer = TestWriter::new();
5305
5306        // Handle the protocol
5307        let result = handle(
5308            &protocol,
5309            &state,
5310            &RenderOptions::new("index.html", "/"),
5311            &mut writer,
5312        );
5313
5314        // Expect an error
5315        assert!(result.is_err());
5316        if let Err(HandlerError::MissingFragment(fragment_id)) = result {
5317            assert_eq!(fragment_id, "missing-component");
5318        } else {
5319            panic!("Expected MissingFragment error");
5320        }
5321    }
5322
5323    #[test]
5324    fn test_missing_signal_renders_empty() {
5325        // A signal referencing a field absent from state should render as empty
5326        let mut fragments = HashMap::new();
5327        fragments.insert(
5328            "index.html".to_string(),
5329            FragmentList {
5330                fragments: vec![
5331                    WebUIFragment::raw("Hello, "),
5332                    WebUIFragment::signal("missing_field", false),
5333                    WebUIFragment::raw("!"),
5334                ],
5335                contains_boundary: false,
5336            },
5337        );
5338
5339        let protocol = WebUIProtocol::new(fragments);
5340        let state = test_json!({});
5341
5342        let mut writer = TestWriter::new();
5343
5344        assert!(
5345            handle(
5346                &protocol,
5347                &state,
5348                &RenderOptions::new("index.html", "/"),
5349                &mut writer
5350            )
5351            .is_ok(),
5352            "Missing signal should not produce an error"
5353        );
5354
5355        assert_eq!(writer.get_content(), "Hello, !");
5356        assert!(writer.is_ended());
5357    }
5358
5359    // ── Boolean attribute rendering tests ─────────────────────────────
5360
5361    #[test]
5362    fn test_boolean_attr_true() {
5363        let mut fragments = HashMap::new();
5364        fragments.insert(
5365            "index.html".to_string(),
5366            FragmentList {
5367                fragments: vec![
5368                    WebUIFragment::raw("<button"),
5369                    WebUIFragment::attribute_boolean(
5370                        "disabled",
5371                        ConditionExpr::identifier("isDisabled"),
5372                    ),
5373                    WebUIFragment::raw(">Click</button>"),
5374                ],
5375                contains_boundary: false,
5376            },
5377        );
5378        let protocol = WebUIProtocol::new(fragments);
5379        let state = test_json!({"isDisabled": true});
5380        let mut writer = TestWriter::new();
5381        handle(
5382            &protocol,
5383            &state,
5384            &RenderOptions::new("index.html", "/"),
5385            &mut writer,
5386        )
5387        .unwrap();
5388        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
5389    }
5390
5391    #[test]
5392    fn test_boolean_attr_false() {
5393        let mut fragments = HashMap::new();
5394        fragments.insert(
5395            "index.html".to_string(),
5396            FragmentList {
5397                fragments: vec![
5398                    WebUIFragment::raw("<button"),
5399                    WebUIFragment::attribute_boolean(
5400                        "disabled",
5401                        ConditionExpr::identifier("isDisabled"),
5402                    ),
5403                    WebUIFragment::raw(">Click</button>"),
5404                ],
5405                contains_boundary: false,
5406            },
5407        );
5408        let protocol = WebUIProtocol::new(fragments);
5409        let state = test_json!({"isDisabled": false});
5410        let mut writer = TestWriter::new();
5411        handle(
5412            &protocol,
5413            &state,
5414            &RenderOptions::new("index.html", "/"),
5415            &mut writer,
5416        )
5417        .unwrap();
5418        assert_eq!(writer.get_content(), "<button>Click</button>");
5419    }
5420
5421    #[test]
5422    fn test_boolean_attr_missing() {
5423        let mut fragments = HashMap::new();
5424        fragments.insert(
5425            "index.html".to_string(),
5426            FragmentList {
5427                fragments: vec![
5428                    WebUIFragment::raw("<input type=\"checkbox\""),
5429                    WebUIFragment::attribute_boolean(
5430                        "checked",
5431                        ConditionExpr::identifier("checked"),
5432                    ),
5433                    WebUIFragment::raw(">"),
5434                ],
5435                contains_boundary: false,
5436            },
5437        );
5438        let protocol = WebUIProtocol::new(fragments);
5439        let state = test_json!({});
5440        let mut writer = TestWriter::new();
5441        handle(
5442            &protocol,
5443            &state,
5444            &RenderOptions::new("index.html", "/"),
5445            &mut writer,
5446        )
5447        .unwrap();
5448        assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
5449    }
5450
5451    #[test]
5452    fn test_boolean_attr_multiple() {
5453        let mut fragments = HashMap::new();
5454        fragments.insert(
5455            "index.html".to_string(),
5456            FragmentList {
5457                fragments: vec![
5458                    WebUIFragment::raw("<input type=\"checkbox\""),
5459                    WebUIFragment::attribute_boolean(
5460                        "checked",
5461                        ConditionExpr::identifier("checked"),
5462                    ),
5463                    WebUIFragment::attribute_boolean(
5464                        "disabled",
5465                        ConditionExpr::identifier("disabled"),
5466                    ),
5467                    WebUIFragment::raw(">"),
5468                ],
5469                contains_boundary: false,
5470            },
5471        );
5472        let protocol = WebUIProtocol::new(fragments);
5473        let state = test_json!({"checked": true, "disabled": false});
5474        let mut writer = TestWriter::new();
5475        handle(
5476            &protocol,
5477            &state,
5478            &RenderOptions::new("index.html", "/"),
5479            &mut writer,
5480        )
5481        .unwrap();
5482        assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
5483    }
5484
5485    // ── Simple attribute rendering tests ──────────────────────────────
5486
5487    #[test]
5488    fn test_attribute_with_value() {
5489        let mut fragments = HashMap::new();
5490        fragments.insert(
5491            "index.html".to_string(),
5492            FragmentList {
5493                fragments: vec![
5494                    WebUIFragment::raw("<input"),
5495                    WebUIFragment::attribute("value", "inputValue"),
5496                    WebUIFragment::raw(">"),
5497                ],
5498                contains_boundary: false,
5499            },
5500        );
5501        let protocol = WebUIProtocol::new(fragments);
5502        let state = test_json!({"inputValue": "Hello"});
5503        let mut writer = TestWriter::new();
5504        handle(
5505            &protocol,
5506            &state,
5507            &RenderOptions::new("index.html", "/"),
5508            &mut writer,
5509        )
5510        .unwrap();
5511        assert_eq!(writer.get_content(), "<input value=\"Hello\">");
5512    }
5513
5514    #[test]
5515    fn test_attribute_with_falsy_numeric() {
5516        let mut fragments = HashMap::new();
5517        fragments.insert(
5518            "index.html".to_string(),
5519            FragmentList {
5520                fragments: vec![
5521                    WebUIFragment::raw("<div name=\"test\""),
5522                    WebUIFragment::attribute("handle", "number"),
5523                    WebUIFragment::raw("></div>"),
5524                ],
5525                contains_boundary: false,
5526            },
5527        );
5528        let protocol = WebUIProtocol::new(fragments);
5529        let state = test_json!({"number": 0});
5530        let mut writer = TestWriter::new();
5531        handle(
5532            &protocol,
5533            &state,
5534            &RenderOptions::new("index.html", "/"),
5535            &mut writer,
5536        )
5537        .unwrap();
5538        assert_eq!(
5539            writer.get_content(),
5540            "<div name=\"test\" handle=\"0\"></div>"
5541        );
5542    }
5543
5544    // ── Dynamic attribute escaping for non-string JSON types ─────────
5545
5546    #[test]
5547    fn test_attribute_array_value_is_escaped() {
5548        let mut fragments = HashMap::new();
5549        fragments.insert(
5550            "index.html".to_string(),
5551            FragmentList {
5552                fragments: vec![
5553                    WebUIFragment::raw("<a"),
5554                    WebUIFragment::attribute("href", "value"),
5555                    WebUIFragment::raw(">demo</a>"),
5556                ],
5557                contains_boundary: false,
5558            },
5559        );
5560        let protocol = WebUIProtocol::new(fragments);
5561        let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
5562        let mut writer = TestWriter::new();
5563        handle(
5564            &protocol,
5565            &state,
5566            &RenderOptions::new("index.html", "/"),
5567            &mut writer,
5568        )
5569        .unwrap();
5570        let content = writer.get_content();
5571        // All inner double quotes must be entity-escaped so that the
5572        // browser never sees a second attribute boundary.
5573        assert!(
5574            content.contains("&quot;"),
5575            "Double quotes inside attribute value must be escaped: {content}"
5576        );
5577        // The href attribute value must be a single contiguous quoted
5578        // string — no extra attributes should appear.
5579        assert_eq!(
5580            content.matches("=\"").count(),
5581            1,
5582            "Only one attribute assignment expected: {content}"
5583        );
5584    }
5585
5586    #[test]
5587    fn test_attribute_object_value_is_escaped() {
5588        let mut fragments = HashMap::new();
5589        fragments.insert(
5590            "index.html".to_string(),
5591            FragmentList {
5592                fragments: vec![
5593                    WebUIFragment::raw("<div"),
5594                    WebUIFragment::attribute("data-cfg", "cfg"),
5595                    WebUIFragment::raw("></div>"),
5596                ],
5597                contains_boundary: false,
5598            },
5599        );
5600        let protocol = WebUIProtocol::new(fragments);
5601        let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
5602        let mut writer = TestWriter::new();
5603        handle(
5604            &protocol,
5605            &state,
5606            &RenderOptions::new("index.html", "/"),
5607            &mut writer,
5608        )
5609        .unwrap();
5610        let content = writer.get_content();
5611        assert!(
5612            content.contains("&quot;"),
5613            "Double quotes inside attribute value must be escaped: {content}"
5614        );
5615        assert_eq!(
5616            content.matches("=\"").count(),
5617            1,
5618            "Only one attribute assignment expected: {content}"
5619        );
5620    }
5621
5622    // ── Template attribute rendering tests ────────────────────────────
5623
5624    #[test]
5625    fn test_mixed_attribute_template() {
5626        let mut fragments = HashMap::new();
5627        fragments.insert(
5628            "index.html".to_string(),
5629            FragmentList {
5630                fragments: vec![
5631                    WebUIFragment::raw("<input"),
5632                    WebUIFragment::attribute_template("value", "attr-1"),
5633                    WebUIFragment::raw(">"),
5634                ],
5635                contains_boundary: false,
5636            },
5637        );
5638        fragments.insert(
5639            "attr-1".to_string(),
5640            FragmentList {
5641                fragments: vec![
5642                    WebUIFragment::raw("hello "),
5643                    WebUIFragment::signal("item", false),
5644                ],
5645                contains_boundary: false,
5646            },
5647        );
5648        let protocol = WebUIProtocol::new(fragments);
5649        let state = test_json!({"item": "world"});
5650        let mut writer = TestWriter::new();
5651        handle(
5652            &protocol,
5653            &state,
5654            &RenderOptions::new("index.html", "/"),
5655            &mut writer,
5656        )
5657        .unwrap();
5658        assert_eq!(writer.get_content(), "<input value=\"hello world\">");
5659    }
5660
5661    // ── Raw signal rendering test ─────────────────────────────────────
5662
5663    #[test]
5664    fn test_raw_signal_not_escaped() {
5665        let mut fragments = HashMap::new();
5666        fragments.insert(
5667            "index.html".to_string(),
5668            FragmentList {
5669                fragments: vec![
5670                    WebUIFragment::signal("html", false),
5671                    WebUIFragment::signal("html", true),
5672                ],
5673                contains_boundary: false,
5674            },
5675        );
5676        let protocol = WebUIProtocol::new(fragments);
5677        let state = test_json!({"html": "<strong>hi</strong>"});
5678        let mut writer = TestWriter::new();
5679        handle(
5680            &protocol,
5681            &state,
5682            &RenderOptions::new("index.html", "/"),
5683            &mut writer,
5684        )
5685        .unwrap();
5686        assert_eq!(
5687            writer.get_content(),
5688            "&lt;strong&gt;hi&lt;&#x2F;strong&gt;<strong>hi</strong>"
5689        );
5690    }
5691
5692    // ── Nested for loop tests ─────────────────────────────────────────
5693
5694    fn render_named_for(template: &str, state: &Value) -> String {
5695        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5696        parser.parse("index.html", template).unwrap();
5697        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
5698        let mut writer = TestWriter::new();
5699        WebUIHandler::new()
5700            .render(
5701                &protocol,
5702                state,
5703                &RenderOptions::new("index.html", "/"),
5704                &mut writer,
5705            )
5706            .unwrap();
5707        writer.get_content()
5708    }
5709
5710    #[test]
5711    fn named_for_renders_the_issue_tree_and_restores_shadowed_items() {
5712        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>"#;
5713        let state = test_json!({
5714            "child": { "name": "global" },
5715            "items": [
5716                { "name": "Colors", "children": [{ "name": "Ali" }, { "name": "Alice" }, { "name": "Bob" }] },
5717                { "name": "Name", "children": [] },
5718                { "name": "Hobbies", "children": [{ "name": "Sports", "children": [{ "name": "Futbol" }, { "name": "Cricket" }] }] }
5719            ]
5720        });
5721        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>");
5722    }
5723
5724    #[test]
5725    fn named_for_missing_and_empty_children_stop_recursion_without_an_if() {
5726        let source = r#"<for id="tree" each="item in items">{{item.name}}<for id="tree" each="item in item.children" />{{item.name}}</for>"#;
5727        let state = test_json!({
5728            "items": [{ "name": "a", "children": [{ "name": "b" }, { "name": "c", "children": [] }] }]
5729        });
5730        assert_eq!(render_named_for(source, &state), "abbcca");
5731        assert_eq!(render_named_for(source, &test_json!({"items": []})), "");
5732    }
5733
5734    #[test]
5735    fn named_for_forward_and_mutual_references_follow_finite_data() {
5736        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>"#;
5737        let state = test_json!({
5738            "items": [{ "name": "a", "children": [{ "name": "b", "children": [{ "name": "c" }] }] }],
5739            "empty": []
5740        });
5741        assert_eq!(render_named_for(source, &state), "<b>a</b><i>b</i><b>c</b>");
5742    }
5743
5744    #[test]
5745    fn named_for_preserves_enclosing_scope_and_escapes_nested_values() {
5746        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>"#;
5747        let state = test_json!({
5748            "groups": [
5749                { "name": "one", "items": [{ "name": "<root>", "children": [{ "name": "&leaf" }] }] },
5750                { "name": "two", "items": [{ "name": "last" }] }
5751            ]
5752        });
5753        assert_eq!(
5754            render_named_for(source, &state),
5755            "one:&lt;root&gt;;one:&amp;leaf;two:last;"
5756        );
5757    }
5758
5759    #[test]
5760    fn named_for_non_array_collection_returns_the_existing_type_error() {
5761        let mut parser = HtmlParser::new();
5762        parser.parse("index.html", r#"<for id="tree" each="item in items"><for id="tree" each="item in item.children" /></for>"#).unwrap();
5763        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
5764        let mut writer = TestWriter::new();
5765        let error = WebUIHandler::new()
5766            .render(
5767                &protocol,
5768                &test_json!({"items": [{"children": 1}]}),
5769                &RenderOptions::new("index.html", "/"),
5770                &mut writer,
5771            )
5772            .unwrap_err();
5773        assert!(matches!(error, HandlerError::TypeError(_)));
5774    }
5775
5776    #[test]
5777    fn test_nested_for_loop() {
5778        let mut fragments = HashMap::new();
5779        fragments.insert(
5780            "index.html".to_string(),
5781            FragmentList {
5782                fragments: vec![
5783                    WebUIFragment::raw("<div>"),
5784                    WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
5785                    WebUIFragment::raw("</div>"),
5786                ],
5787                contains_boundary: false,
5788            },
5789        );
5790        fragments.insert(
5791            "outer".to_string(),
5792            FragmentList {
5793                fragments: vec![
5794                    WebUIFragment::raw("<div>"),
5795                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
5796                    WebUIFragment::raw("</div>"),
5797                ],
5798                contains_boundary: false,
5799            },
5800        );
5801        fragments.insert(
5802            "inner".to_string(),
5803            FragmentList {
5804                fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
5805                contains_boundary: false,
5806            },
5807        );
5808        let protocol = WebUIProtocol::new(fragments);
5809        let state = test_json!({
5810            "outerItems": [
5811                {"innerItems": [{"name": "A"}, {"name": "B"}]},
5812                {"innerItems": [{"name": "C"}]}
5813            ]
5814        });
5815        let mut writer = TestWriter::new();
5816        handle(
5817            &protocol,
5818            &state,
5819            &RenderOptions::new("index.html", "/"),
5820            &mut writer,
5821        )
5822        .unwrap();
5823        assert_eq!(
5824            writer.get_content(),
5825            "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
5826        );
5827    }
5828
5829    #[test]
5830    fn test_nested_for_with_signals() {
5831        let mut fragments = HashMap::new();
5832        fragments.insert(
5833            "index.html".to_string(),
5834            FragmentList {
5835                fragments: vec![
5836                    WebUIFragment::raw("<div>"),
5837                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5838                    WebUIFragment::raw("</div>"),
5839                ],
5840                contains_boundary: false,
5841            },
5842        );
5843        fragments.insert(
5844            "outerTemplate".to_string(),
5845            FragmentList {
5846                fragments: vec![
5847                    WebUIFragment::raw("<div>"),
5848                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5849                    WebUIFragment::raw("</div>"),
5850                ],
5851                contains_boundary: false,
5852            },
5853        );
5854        fragments.insert(
5855            "innerTemplate".to_string(),
5856            FragmentList {
5857                fragments: vec![
5858                    WebUIFragment::raw("<span>"),
5859                    WebUIFragment::signal("innerItem.name", false),
5860                    WebUIFragment::raw("</span>"),
5861                ],
5862                contains_boundary: false,
5863            },
5864        );
5865        let protocol = WebUIProtocol::new(fragments);
5866        let state = test_json!({
5867            "outerItems": [
5868                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5869                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5870            ]
5871        });
5872        let mut writer = TestWriter::new();
5873        handle(
5874            &protocol,
5875            &state,
5876            &RenderOptions::new("index.html", "/"),
5877            &mut writer,
5878        )
5879        .unwrap();
5880        assert_eq!(
5881            writer.get_content(),
5882            "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
5883        );
5884    }
5885
5886    #[test]
5887    fn test_nested_for_with_global_state() {
5888        let mut fragments = HashMap::new();
5889        fragments.insert(
5890            "index.html".to_string(),
5891            FragmentList {
5892                fragments: vec![
5893                    WebUIFragment::raw("<div>"),
5894                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5895                    WebUIFragment::raw("</div>"),
5896                ],
5897                contains_boundary: false,
5898            },
5899        );
5900        fragments.insert(
5901            "outerTemplate".to_string(),
5902            FragmentList {
5903                fragments: vec![
5904                    WebUIFragment::raw("<div>"),
5905                    WebUIFragment::signal("globalOuter", false),
5906                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5907                    WebUIFragment::raw("</div>"),
5908                ],
5909                contains_boundary: false,
5910            },
5911        );
5912        fragments.insert(
5913            "innerTemplate".to_string(),
5914            FragmentList {
5915                fragments: vec![
5916                    WebUIFragment::raw("<span>"),
5917                    WebUIFragment::signal("innerItem.name", false),
5918                    WebUIFragment::signal("globalInner", false),
5919                    WebUIFragment::raw("</span>"),
5920                ],
5921                contains_boundary: false,
5922            },
5923        );
5924        let protocol = WebUIProtocol::new(fragments);
5925        let state = test_json!({
5926            "globalOuter": "GO",
5927            "globalInner": "GI",
5928            "outerItems": [
5929                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5930                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5931            ]
5932        });
5933        let mut writer = TestWriter::new();
5934        handle(
5935            &protocol,
5936            &state,
5937            &RenderOptions::new("index.html", "/"),
5938            &mut writer,
5939        )
5940        .unwrap();
5941        assert_eq!(
5942            writer.get_content(),
5943            "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
5944        );
5945    }
5946
5947    // ── For + If state scoping tests ──────────────────────────────────
5948
5949    #[test]
5950    fn test_if_in_for_uses_local_state() {
5951        let mut fragments = HashMap::new();
5952        fragments.insert(
5953            "index.html".to_string(),
5954            FragmentList {
5955                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5956                contains_boundary: false,
5957            },
5958        );
5959        fragments.insert(
5960            "item-tpl".to_string(),
5961            FragmentList {
5962                fragments: vec![WebUIFragment::if_cond(
5963                    ConditionExpr::identifier("item.visible"),
5964                    "visible-tpl",
5965                )],
5966                contains_boundary: false,
5967            },
5968        );
5969        fragments.insert(
5970            "visible-tpl".to_string(),
5971            FragmentList {
5972                fragments: vec![WebUIFragment::signal("item.name", false)],
5973                contains_boundary: false,
5974            },
5975        );
5976        let protocol = WebUIProtocol::new(fragments);
5977        let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
5978        let mut writer = TestWriter::new();
5979        handle(
5980            &protocol,
5981            &state,
5982            &RenderOptions::new("index.html", "/"),
5983            &mut writer,
5984        )
5985        .unwrap();
5986        assert_eq!(writer.get_content(), "Show");
5987    }
5988
5989    #[test]
5990    fn test_for_if_local_overrides_global() {
5991        let mut fragments = HashMap::new();
5992        fragments.insert(
5993            "index.html".to_string(),
5994            FragmentList {
5995                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5996                contains_boundary: false,
5997            },
5998        );
5999        fragments.insert(
6000            "item-tpl".to_string(),
6001            FragmentList {
6002                fragments: vec![WebUIFragment::if_cond(
6003                    ConditionExpr::identifier("item.flag"),
6004                    "show-tpl",
6005                )],
6006                contains_boundary: false,
6007            },
6008        );
6009        fragments.insert(
6010            "show-tpl".to_string(),
6011            FragmentList {
6012                fragments: vec![WebUIFragment::raw("yes")],
6013                contains_boundary: false,
6014            },
6015        );
6016        let protocol = WebUIProtocol::new(fragments);
6017        // Global flag is true, but local item.flag is false for second item
6018        let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
6019        let mut writer = TestWriter::new();
6020        handle(
6021            &protocol,
6022            &state,
6023            &RenderOptions::new("index.html", "/"),
6024            &mut writer,
6025        )
6026        .unwrap();
6027        assert_eq!(writer.get_content(), "yes");
6028    }
6029
6030    // ── Component attribute state tests ───────────────────────────────
6031
6032    #[test]
6033    fn html_component_inputs_forward_flags_and_aria_without_javascript_metadata() {
6034        let cases = [
6035            (
6036                r#"<my-drawer open aria-label="Canvas information"></my-drawer>"#,
6037                test_json!({}),
6038                r#"<dialog open aria-label="Canvas information"></dialog>"#,
6039            ),
6040            (
6041                r#"<my-drawer ?open="{{open}}" aria-label="Canvas information"></my-drawer>"#,
6042                test_json!({"open": true}),
6043                r#"<dialog open aria-label="Canvas information"></dialog>"#,
6044            ),
6045            (
6046                r#"<my-drawer ?open="{{open}}" aria-label="Canvas information"></my-drawer>"#,
6047                test_json!({"open": false}),
6048                r#"<dialog aria-label="Canvas information"></dialog>"#,
6049            ),
6050            (
6051                r#"<my-drawer aria-label=""></my-drawer>"#,
6052                test_json!({"ariaLabel": "Fallback"}),
6053                r#"<dialog aria-label=""></dialog>"#,
6054            ),
6055            (
6056                r#"<my-drawer open aria-label="Canvas &amp; information"></my-drawer>"#,
6057                test_json!({}),
6058                r#"<dialog open aria-label="Canvas &amp; information"></dialog>"#,
6059            ),
6060        ];
6061        for (input, state, expected) in cases {
6062            let mut parser = HtmlParser::with_options(DomStrategy::Light);
6063            parser
6064                .component_registry_mut()
6065                .register_component(ComponentRegistration::new(
6066                    "my-drawer",
6067                    r#"<dialog ?open="{{open}}" aria-label="{{ariaLabel}}"></dialog>"#,
6068                    None,
6069                    true,
6070                ))
6071                .unwrap();
6072            parser.parse("index.html", input).unwrap();
6073            let protocol = WebUIProtocol::new(parser.into_fragment_records());
6074            let mut writer = TestWriter::new();
6075            handle(
6076                &protocol,
6077                &state,
6078                &RenderOptions::new("index.html", "/"),
6079                &mut writer,
6080            )
6081            .unwrap();
6082            assert!(
6083                writer.get_content().contains(expected),
6084                "{input}: {}",
6085                writer.get_content()
6086            );
6087        }
6088    }
6089
6090    #[test]
6091    fn test_component_attr_state_simple() {
6092        let mut fragments = HashMap::new();
6093        fragments.insert(
6094            "index.html".to_string(),
6095            FragmentList {
6096                fragments: vec![
6097                    WebUIFragment::raw("<my-comp"),
6098                    WebUIFragment {
6099                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6100                            WebUIFragmentAttribute {
6101                                name: "title".into(),
6102                                value: "Attribute Title".into(),
6103                                attr_start: true,
6104                                raw_value: true,
6105                                ..Default::default()
6106                            },
6107                        )),
6108                    },
6109                    WebUIFragment::raw(">"),
6110                    WebUIFragment::component("my-comp"),
6111                    WebUIFragment::raw("</my-comp>"),
6112                ],
6113
6114                contains_boundary: false,
6115            },
6116        );
6117        fragments.insert(
6118            "my-comp".to_string(),
6119            FragmentList {
6120                fragments: vec![
6121                    WebUIFragment::raw("<span>"),
6122                    WebUIFragment::signal("title", false),
6123                    WebUIFragment::raw("</span>"),
6124                ],
6125                contains_boundary: false,
6126            },
6127        );
6128        let protocol = WebUIProtocol::new(fragments);
6129        let state = test_json!({"title": "Global Title"});
6130        let mut writer = TestWriter::new();
6131        handle(
6132            &protocol,
6133            &state,
6134            &RenderOptions::new("index.html", "/"),
6135            &mut writer,
6136        )
6137        .unwrap();
6138        assert_eq!(
6139            writer.get_content(),
6140            "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
6141        );
6142    }
6143
6144    #[test]
6145    fn test_component_attr_state_template() {
6146        let mut fragments = HashMap::new();
6147        fragments.insert(
6148            "index.html".to_string(),
6149            FragmentList {
6150                fragments: vec![
6151                    WebUIFragment::raw("<my-comp"),
6152                    WebUIFragment {
6153                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6154                            WebUIFragmentAttribute {
6155                                name: "title".into(),
6156                                template: "title-attr".into(),
6157                                attr_start: true,
6158                                ..Default::default()
6159                            },
6160                        )),
6161                    },
6162                    WebUIFragment::raw(">"),
6163                    WebUIFragment::component("my-comp"),
6164                    WebUIFragment::raw("</my-comp>"),
6165                ],
6166
6167                contains_boundary: false,
6168            },
6169        );
6170        fragments.insert(
6171            "title-attr".to_string(),
6172            FragmentList {
6173                fragments: vec![
6174                    WebUIFragment::raw("hello "),
6175                    WebUIFragment::signal("item", false),
6176                ],
6177                contains_boundary: false,
6178            },
6179        );
6180        fragments.insert(
6181            "my-comp".to_string(),
6182            FragmentList {
6183                fragments: vec![
6184                    WebUIFragment::raw("<span>"),
6185                    WebUIFragment::signal("title", false),
6186                    WebUIFragment::raw("</span>"),
6187                ],
6188                contains_boundary: false,
6189            },
6190        );
6191        let protocol = WebUIProtocol::new(fragments);
6192        let state = test_json!({"item": "<world>"});
6193        let mut writer = TestWriter::new();
6194        handle(
6195            &protocol,
6196            &state,
6197            &RenderOptions::new("index.html", "/"),
6198            &mut writer,
6199        )
6200        .unwrap();
6201        assert_eq!(
6202            writer.get_content(),
6203            "<my-comp title=\"hello &lt;world&gt;\"><span>hello &lt;world&gt;</span></my-comp>"
6204        );
6205    }
6206
6207    #[test]
6208    fn test_component_attr_camel_case() {
6209        let mut fragments = HashMap::new();
6210        fragments.insert(
6211            "index.html".to_string(),
6212            FragmentList {
6213                fragments: vec![
6214                    WebUIFragment::raw("<my-comp"),
6215                    WebUIFragment {
6216                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6217                            WebUIFragmentAttribute {
6218                                name: "data-title".into(),
6219                                template: "dt-attr".into(),
6220                                attr_start: true,
6221                                ..Default::default()
6222                            },
6223                        )),
6224                    },
6225                    WebUIFragment::raw(">"),
6226                    WebUIFragment::component("my-comp"),
6227                    WebUIFragment::raw("</my-comp>"),
6228                ],
6229
6230                contains_boundary: false,
6231            },
6232        );
6233        fragments.insert(
6234            "dt-attr".to_string(),
6235            FragmentList {
6236                fragments: vec![
6237                    WebUIFragment::raw("prefix "),
6238                    WebUIFragment::signal("item", false),
6239                ],
6240                contains_boundary: false,
6241            },
6242        );
6243        fragments.insert(
6244            "my-comp".to_string(),
6245            FragmentList {
6246                fragments: vec![
6247                    WebUIFragment::raw("<span>"),
6248                    WebUIFragment::signal("dataTitle", false),
6249                    WebUIFragment::raw("</span>"),
6250                ],
6251                contains_boundary: false,
6252            },
6253        );
6254        let protocol = WebUIProtocol::new(fragments);
6255        let state = test_json!({"item": "a&b"});
6256        let mut writer = TestWriter::new();
6257        handle(
6258            &protocol,
6259            &state,
6260            &RenderOptions::new("index.html", "/"),
6261            &mut writer,
6262        )
6263        .unwrap();
6264        assert_eq!(
6265            writer.get_content(),
6266            "<my-comp data-title=\"prefix a&amp;b\"><span>prefix a&amp;b</span></my-comp>"
6267        );
6268    }
6269
6270    #[test]
6271    fn test_component_complex_attr() {
6272        let mut fragments = HashMap::new();
6273        fragments.insert(
6274            "index.html".to_string(),
6275            FragmentList {
6276                fragments: vec![
6277                    WebUIFragment::raw("<my-comp"),
6278                    WebUIFragment {
6279                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6280                            WebUIFragmentAttribute {
6281                                name: ":item".into(),
6282                                value: "complexItem".into(),
6283                                attr_start: true,
6284                                complex: true,
6285                                ..Default::default()
6286                            },
6287                        )),
6288                    },
6289                    WebUIFragment::raw(">"),
6290                    WebUIFragment::component("my-comp"),
6291                    WebUIFragment::raw("</my-comp>"),
6292                ],
6293
6294                contains_boundary: false,
6295            },
6296        );
6297        fragments.insert(
6298            "my-comp".to_string(),
6299            FragmentList {
6300                fragments: vec![
6301                    WebUIFragment::raw("<span>"),
6302                    WebUIFragment::signal("item.foo", false),
6303                    WebUIFragment::raw("</span><p>"),
6304                    WebUIFragment::signal("item.bar", false),
6305                    WebUIFragment::raw("</p>"),
6306                ],
6307                contains_boundary: false,
6308            },
6309        );
6310        let protocol = WebUIProtocol::new(fragments);
6311        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
6312        let mut writer = TestWriter::new();
6313        handle(
6314            &protocol,
6315            &state,
6316            &RenderOptions::new("index.html", "/"),
6317            &mut writer,
6318        )
6319        .unwrap();
6320        assert_eq!(
6321            writer.get_content(),
6322            "<my-comp><span>1</span><p>true</p></my-comp>"
6323        );
6324    }
6325
6326    #[test]
6327    fn test_component_no_parent_pollution() {
6328        let mut fragments = HashMap::new();
6329        fragments.insert(
6330            "index.html".to_string(),
6331            FragmentList {
6332                fragments: vec![
6333                    WebUIFragment::raw("<parent"),
6334                    WebUIFragment {
6335                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6336                            WebUIFragmentAttribute {
6337                                name: "var".into(),
6338                                value: "var".into(),
6339                                attr_start: true,
6340                                ..Default::default()
6341                            },
6342                        )),
6343                    },
6344                    WebUIFragment::raw(">"),
6345                    WebUIFragment::component("parent"),
6346                    WebUIFragment::raw("</parent>"),
6347                ],
6348
6349                contains_boundary: false,
6350            },
6351        );
6352        fragments.insert(
6353            "parent".to_string(),
6354            FragmentList {
6355                fragments: vec![
6356                    WebUIFragment::raw("Before: "),
6357                    WebUIFragment::signal("var", false),
6358                    WebUIFragment::raw("<child foo"),
6359                    WebUIFragment {
6360                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6361                            WebUIFragmentAttribute {
6362                                name: "var".into(),
6363                                value: "replaced".into(),
6364                                raw_value: true,
6365                                ..Default::default()
6366                            },
6367                        )),
6368                    },
6369                    WebUIFragment::raw(">"),
6370                    WebUIFragment::component("child"),
6371                    WebUIFragment::raw("Label</child>After: "),
6372                    WebUIFragment::signal("var", false),
6373                ],
6374
6375                contains_boundary: false,
6376            },
6377        );
6378        fragments.insert(
6379            "child".to_string(),
6380            FragmentList {
6381                fragments: vec![],
6382                contains_boundary: false,
6383            },
6384        );
6385        let protocol = WebUIProtocol::new(fragments);
6386        let state = test_json!({"var": "original<&"});
6387        let mut writer = TestWriter::new();
6388        handle(
6389            &protocol,
6390            &state,
6391            &RenderOptions::new("index.html", "/"),
6392            &mut writer,
6393        )
6394        .unwrap();
6395        assert_eq!(
6396            writer.get_content(),
6397            "<parent var=\"original&lt;&amp;\">Before: original&lt;&amp;<child foo var=\"replaced\">Label</child>After: original&lt;&amp;</parent>"
6398        );
6399    }
6400
6401    #[test]
6402    fn test_component_boolean_attr_state() {
6403        let mut fragments = HashMap::new();
6404        fragments.insert(
6405            "index.html".to_string(),
6406            FragmentList {
6407                fragments: vec![
6408                    WebUIFragment::raw("<my-comp"),
6409                    WebUIFragment {
6410                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6411                            WebUIFragmentAttribute {
6412                                name: "disabled".into(),
6413                                attr_start: true,
6414                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
6415                                ..Default::default()
6416                            },
6417                        )),
6418                    },
6419                    WebUIFragment::raw(">"),
6420                    WebUIFragment::component("my-comp"),
6421                    WebUIFragment::raw("</my-comp>"),
6422                ],
6423
6424                contains_boundary: false,
6425            },
6426        );
6427        fragments.insert(
6428            "my-comp".to_string(),
6429            FragmentList {
6430                fragments: vec![WebUIFragment::if_cond(
6431                    ConditionExpr::identifier("disabled"),
6432                    "show",
6433                )],
6434                contains_boundary: false,
6435            },
6436        );
6437        fragments.insert(
6438            "show".to_string(),
6439            FragmentList {
6440                fragments: vec![WebUIFragment::raw("disabled!")],
6441                contains_boundary: false,
6442            },
6443        );
6444        let protocol = WebUIProtocol::new(fragments);
6445        let state = test_json!({"isDisabled": true});
6446        let mut writer = TestWriter::new();
6447        handle(
6448            &protocol,
6449            &state,
6450            &RenderOptions::new("index.html", "/"),
6451            &mut writer,
6452        )
6453        .unwrap();
6454        assert_eq!(
6455            writer.get_content(),
6456            "<my-comp disabled>disabled!</my-comp>"
6457        );
6458    }
6459
6460    // ===== HTML Escape Tests (ported from utils.test.js escapeHtml) =====
6461
6462    /// Helper: render a signal value through the handler and return the escaped output.
6463    fn render_signal(value: &str) -> String {
6464        let mut fragments = HashMap::new();
6465        fragments.insert(
6466            "index.html".to_string(),
6467            FragmentList {
6468                fragments: vec![WebUIFragment::signal("v", false)],
6469                contains_boundary: false,
6470            },
6471        );
6472        let protocol = WebUIProtocol::new(fragments);
6473        let state = test_json!({"v": value});
6474        let mut writer = TestWriter::new();
6475        handle(
6476            &protocol,
6477            &state,
6478            &RenderOptions::new("index.html", "/"),
6479            &mut writer,
6480        )
6481        .unwrap();
6482        writer.get_content()
6483    }
6484
6485    #[test]
6486    fn test_escape_ampersand() {
6487        assert_eq!(render_signal("&"), "&amp;");
6488    }
6489
6490    #[test]
6491    fn test_escape_less_than() {
6492        assert_eq!(render_signal("<"), "&lt;");
6493    }
6494
6495    #[test]
6496    fn test_escape_greater_than() {
6497        assert_eq!(render_signal(">"), "&gt;");
6498    }
6499
6500    #[test]
6501    fn test_escape_double_quote() {
6502        assert_eq!(render_signal("\""), "&quot;");
6503    }
6504
6505    #[test]
6506    fn test_escape_single_quote() {
6507        // encode_safe escapes ' as &#x27;
6508        let result = render_signal("'");
6509        assert_eq!(
6510            result, "&#x27;",
6511            "Expected &#x27; for single quote, got: {result}"
6512        );
6513    }
6514
6515    #[test]
6516    fn test_escape_multiple_special_chars() {
6517        let result = render_signal("<script>alert('xss');</script>");
6518        assert!(
6519            result.contains("&lt;") && result.contains("&gt;"),
6520            "Expected escaped HTML, got: {}",
6521            result
6522        );
6523        assert!(
6524            !result.contains("<script>"),
6525            "Should not contain raw <script> tag"
6526        );
6527    }
6528
6529    #[test]
6530    fn test_escape_no_special_chars() {
6531        assert_eq!(render_signal("Hello World"), "Hello World");
6532    }
6533
6534    #[test]
6535    fn test_escape_empty_string() {
6536        assert_eq!(render_signal(""), "");
6537    }
6538
6539    #[test]
6540    fn test_escape_special_at_beginning() {
6541        let result = render_signal("<Hello");
6542        assert!(
6543            result.starts_with("&lt;"),
6544            "Expected &lt; at start, got: {}",
6545            result
6546        );
6547    }
6548
6549    #[test]
6550    fn test_escape_special_at_end() {
6551        let result = render_signal("Hello>");
6552        assert!(
6553            result.ends_with("&gt;"),
6554            "Expected &gt; at end, got: {}",
6555            result
6556        );
6557    }
6558
6559    #[test]
6560    fn test_escape_special_in_middle() {
6561        let result = render_signal("Hel&lo");
6562        assert!(
6563            result.contains("&amp;"),
6564            "Expected &amp; in middle, got: {}",
6565            result
6566        );
6567    }
6568
6569    // ── GROUP 5: Boolean Attribute Edge Cases ─────────────────────────
6570
6571    #[test]
6572    fn test_boolean_attr_truthy_values() {
6573        // checked: 1
6574        {
6575            let mut fragments = HashMap::new();
6576            fragments.insert(
6577                "index.html".to_string(),
6578                FragmentList {
6579                    fragments: vec![
6580                        WebUIFragment::raw("<input"),
6581                        WebUIFragment::attribute_boolean(
6582                            "checked",
6583                            ConditionExpr::identifier("checked"),
6584                        ),
6585                        WebUIFragment::raw(">"),
6586                    ],
6587                    contains_boundary: false,
6588                },
6589            );
6590            let protocol = WebUIProtocol::new(fragments);
6591            let state = test_json!({"checked": 1});
6592            let mut writer = TestWriter::new();
6593            handle(
6594                &protocol,
6595                &state,
6596                &RenderOptions::new("index.html", "/"),
6597                &mut writer,
6598            )
6599            .unwrap();
6600            assert_eq!(writer.get_content(), "<input checked>");
6601        }
6602        // checked: "yes"
6603        {
6604            let mut fragments = HashMap::new();
6605            fragments.insert(
6606                "index.html".to_string(),
6607                FragmentList {
6608                    fragments: vec![
6609                        WebUIFragment::raw("<input"),
6610                        WebUIFragment::attribute_boolean(
6611                            "checked",
6612                            ConditionExpr::identifier("checked"),
6613                        ),
6614                        WebUIFragment::raw(">"),
6615                    ],
6616                    contains_boundary: false,
6617                },
6618            );
6619            let protocol = WebUIProtocol::new(fragments);
6620            let state = test_json!({"checked": "yes"});
6621            let mut writer = TestWriter::new();
6622            handle(
6623                &protocol,
6624                &state,
6625                &RenderOptions::new("index.html", "/"),
6626                &mut writer,
6627            )
6628            .unwrap();
6629            assert_eq!(writer.get_content(), "<input checked>");
6630        }
6631        // checked: {} (empty object is truthy)
6632        {
6633            let mut fragments = HashMap::new();
6634            fragments.insert(
6635                "index.html".to_string(),
6636                FragmentList {
6637                    fragments: vec![
6638                        WebUIFragment::raw("<input"),
6639                        WebUIFragment::attribute_boolean(
6640                            "checked",
6641                            ConditionExpr::identifier("checked"),
6642                        ),
6643                        WebUIFragment::raw(">"),
6644                    ],
6645                    contains_boundary: false,
6646                },
6647            );
6648            let protocol = WebUIProtocol::new(fragments);
6649            let state = test_json!({"checked": {}});
6650            let mut writer = TestWriter::new();
6651            handle(
6652                &protocol,
6653                &state,
6654                &RenderOptions::new("index.html", "/"),
6655                &mut writer,
6656            )
6657            .unwrap();
6658            // Empty object is falsy in this expression evaluator
6659            assert_eq!(writer.get_content(), "<input>");
6660        }
6661        // checked: "false" (string "false" is truthy)
6662        {
6663            let mut fragments = HashMap::new();
6664            fragments.insert(
6665                "index.html".to_string(),
6666                FragmentList {
6667                    fragments: vec![
6668                        WebUIFragment::raw("<input"),
6669                        WebUIFragment::attribute_boolean(
6670                            "checked",
6671                            ConditionExpr::identifier("checked"),
6672                        ),
6673                        WebUIFragment::raw(">"),
6674                    ],
6675                    contains_boundary: false,
6676                },
6677            );
6678            let protocol = WebUIProtocol::new(fragments);
6679            let state = test_json!({"checked": "false"});
6680            let mut writer = TestWriter::new();
6681            handle(
6682                &protocol,
6683                &state,
6684                &RenderOptions::new("index.html", "/"),
6685                &mut writer,
6686            )
6687            .unwrap();
6688            assert_eq!(writer.get_content(), "<input checked>");
6689        }
6690    }
6691
6692    #[test]
6693    fn test_boolean_attr_falsy_values() {
6694        // checked: 0
6695        {
6696            let mut fragments = HashMap::new();
6697            fragments.insert(
6698                "index.html".to_string(),
6699                FragmentList {
6700                    fragments: vec![
6701                        WebUIFragment::raw("<input"),
6702                        WebUIFragment::attribute_boolean(
6703                            "checked",
6704                            ConditionExpr::identifier("checked"),
6705                        ),
6706                        WebUIFragment::raw(">"),
6707                    ],
6708                    contains_boundary: false,
6709                },
6710            );
6711            let protocol = WebUIProtocol::new(fragments);
6712            let state = test_json!({"checked": 0});
6713            let mut writer = TestWriter::new();
6714            handle(
6715                &protocol,
6716                &state,
6717                &RenderOptions::new("index.html", "/"),
6718                &mut writer,
6719            )
6720            .unwrap();
6721            assert_eq!(writer.get_content(), "<input>");
6722        }
6723        // checked: ""
6724        {
6725            let mut fragments = HashMap::new();
6726            fragments.insert(
6727                "index.html".to_string(),
6728                FragmentList {
6729                    fragments: vec![
6730                        WebUIFragment::raw("<input"),
6731                        WebUIFragment::attribute_boolean(
6732                            "checked",
6733                            ConditionExpr::identifier("checked"),
6734                        ),
6735                        WebUIFragment::raw(">"),
6736                    ],
6737                    contains_boundary: false,
6738                },
6739            );
6740            let protocol = WebUIProtocol::new(fragments);
6741            let state = test_json!({"checked": ""});
6742            let mut writer = TestWriter::new();
6743            handle(
6744                &protocol,
6745                &state,
6746                &RenderOptions::new("index.html", "/"),
6747                &mut writer,
6748            )
6749            .unwrap();
6750            assert_eq!(writer.get_content(), "<input>");
6751        }
6752        // checked: false
6753        {
6754            let mut fragments = HashMap::new();
6755            fragments.insert(
6756                "index.html".to_string(),
6757                FragmentList {
6758                    fragments: vec![
6759                        WebUIFragment::raw("<input"),
6760                        WebUIFragment::attribute_boolean(
6761                            "checked",
6762                            ConditionExpr::identifier("checked"),
6763                        ),
6764                        WebUIFragment::raw(">"),
6765                    ],
6766                    contains_boundary: false,
6767                },
6768            );
6769            let protocol = WebUIProtocol::new(fragments);
6770            let state = test_json!({"checked": false});
6771            let mut writer = TestWriter::new();
6772            handle(
6773                &protocol,
6774                &state,
6775                &RenderOptions::new("index.html", "/"),
6776                &mut writer,
6777            )
6778            .unwrap();
6779            assert_eq!(writer.get_content(), "<input>");
6780        }
6781        // no checked key at all
6782        {
6783            let mut fragments = HashMap::new();
6784            fragments.insert(
6785                "index.html".to_string(),
6786                FragmentList {
6787                    fragments: vec![
6788                        WebUIFragment::raw("<input"),
6789                        WebUIFragment::attribute_boolean(
6790                            "checked",
6791                            ConditionExpr::identifier("checked"),
6792                        ),
6793                        WebUIFragment::raw(">"),
6794                    ],
6795                    contains_boundary: false,
6796                },
6797            );
6798            let protocol = WebUIProtocol::new(fragments);
6799            let state = test_json!({});
6800            let mut writer = TestWriter::new();
6801            handle(
6802                &protocol,
6803                &state,
6804                &RenderOptions::new("index.html", "/"),
6805                &mut writer,
6806            )
6807            .unwrap();
6808            assert_eq!(writer.get_content(), "<input>");
6809        }
6810    }
6811
6812    #[test]
6813    fn test_boolean_attr_expression_true() {
6814        let mut fragments = HashMap::new();
6815        fragments.insert(
6816            "index.html".to_string(),
6817            FragmentList {
6818                fragments: vec![
6819                    WebUIFragment::raw("<button"),
6820                    WebUIFragment::attribute_boolean(
6821                        "disabled",
6822                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6823                    ),
6824                    WebUIFragment::raw(">Click</button>"),
6825                ],
6826                contains_boundary: false,
6827            },
6828        );
6829        let protocol = WebUIProtocol::new(fragments);
6830        let state = test_json!({"itemCount": 5});
6831        let mut writer = TestWriter::new();
6832        handle(
6833            &protocol,
6834            &state,
6835            &RenderOptions::new("index.html", "/"),
6836            &mut writer,
6837        )
6838        .unwrap();
6839        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
6840    }
6841
6842    #[test]
6843    fn test_boolean_attr_expression_false() {
6844        let mut fragments = HashMap::new();
6845        fragments.insert(
6846            "index.html".to_string(),
6847            FragmentList {
6848                fragments: vec![
6849                    WebUIFragment::raw("<button"),
6850                    WebUIFragment::attribute_boolean(
6851                        "disabled",
6852                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6853                    ),
6854                    WebUIFragment::raw(">Click</button>"),
6855                ],
6856                contains_boundary: false,
6857            },
6858        );
6859        let protocol = WebUIProtocol::new(fragments);
6860        let state = test_json!({"itemCount": 3});
6861        let mut writer = TestWriter::new();
6862        handle(
6863            &protocol,
6864            &state,
6865            &RenderOptions::new("index.html", "/"),
6866            &mut writer,
6867        )
6868        .unwrap();
6869        assert_eq!(writer.get_content(), "<button>Click</button>");
6870    }
6871
6872    // ── GROUP 6: Mixed Attributes ─────────────────────────────────────
6873
6874    #[test]
6875    fn test_nested_component_attr_capture() {
6876        let mut fragments = HashMap::new();
6877        fragments.insert(
6878            "index.html".to_string(),
6879            FragmentList {
6880                fragments: vec![
6881                    WebUIFragment::raw("<parent-component"),
6882                    WebUIFragment {
6883                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6884                            WebUIFragmentAttribute {
6885                                name: "title".into(),
6886                                template: "parent-title".into(),
6887                                attr_start: true,
6888                                ..Default::default()
6889                            },
6890                        )),
6891                    },
6892                    WebUIFragment::raw(">"),
6893                    WebUIFragment::component("parent-component"),
6894                    WebUIFragment::raw("</parent-component>"),
6895                ],
6896
6897                contains_boundary: false,
6898            },
6899        );
6900        fragments.insert(
6901            "parent-title".to_string(),
6902            FragmentList {
6903                fragments: vec![
6904                    WebUIFragment::raw("Hello "),
6905                    WebUIFragment::signal("who", false),
6906                ],
6907                contains_boundary: false,
6908            },
6909        );
6910        fragments.insert(
6911            "parent-component".to_string(),
6912            FragmentList {
6913                fragments: vec![
6914                    WebUIFragment::raw("<child-component"),
6915                    WebUIFragment {
6916                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6917                            WebUIFragmentAttribute {
6918                                name: "title".into(),
6919                                template: "child-title".into(),
6920                                attr_start: true,
6921                                ..Default::default()
6922                            },
6923                        )),
6924                    },
6925                    WebUIFragment::raw(">"),
6926                    WebUIFragment::component("child-component"),
6927                    WebUIFragment::raw("</child-component>"),
6928                ],
6929
6930                contains_boundary: false,
6931            },
6932        );
6933        fragments.insert(
6934            "child-title".to_string(),
6935            FragmentList {
6936                fragments: vec![
6937                    WebUIFragment::raw("Child of "),
6938                    WebUIFragment::signal("title", false),
6939                ],
6940                contains_boundary: false,
6941            },
6942        );
6943        fragments.insert(
6944            "child-component".to_string(),
6945            FragmentList {
6946                fragments: vec![
6947                    WebUIFragment::raw("<span>"),
6948                    WebUIFragment::signal("title", false),
6949                    WebUIFragment::raw("</span>"),
6950                ],
6951                contains_boundary: false,
6952            },
6953        );
6954        let protocol = WebUIProtocol::new(fragments);
6955        let state = test_json!({"who": "<world>"});
6956        let mut writer = TestWriter::new();
6957        handle(
6958            &protocol,
6959            &state,
6960            &RenderOptions::new("index.html", "/"),
6961            &mut writer,
6962        )
6963        .unwrap();
6964        assert_eq!(
6965            writer.get_content(),
6966            "<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>"
6967        );
6968    }
6969
6970    #[test]
6971    fn test_grandchild_attr_propagation() {
6972        let mut fragments = HashMap::new();
6973        fragments.insert(
6974            "index.html".to_string(),
6975            FragmentList {
6976                fragments: vec![
6977                    WebUIFragment::raw("<parent-component"),
6978                    WebUIFragment {
6979                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6980                            WebUIFragmentAttribute {
6981                                name: "title".into(),
6982                                template: "p-title".into(),
6983                                attr_start: true,
6984                                ..Default::default()
6985                            },
6986                        )),
6987                    },
6988                    WebUIFragment::raw(">"),
6989                    WebUIFragment::component("parent-component"),
6990                    WebUIFragment::raw("</parent-component>"),
6991                ],
6992
6993                contains_boundary: false,
6994            },
6995        );
6996        fragments.insert(
6997            "p-title".to_string(),
6998            FragmentList {
6999                fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
7000                contains_boundary: false,
7001            },
7002        );
7003        fragments.insert(
7004            "parent-component".to_string(),
7005            FragmentList {
7006                fragments: vec![
7007                    WebUIFragment::raw("<child-component"),
7008                    WebUIFragment {
7009                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7010                            WebUIFragmentAttribute {
7011                                name: "title".into(),
7012                                template: "c-title".into(),
7013                                attr_start: true,
7014                                ..Default::default()
7015                            },
7016                        )),
7017                    },
7018                    WebUIFragment::raw(">"),
7019                    WebUIFragment::component("child-component"),
7020                    WebUIFragment::raw("</child-component>"),
7021                ],
7022
7023                contains_boundary: false,
7024            },
7025        );
7026        fragments.insert(
7027            "c-title".to_string(),
7028            FragmentList {
7029                fragments: vec![
7030                    WebUIFragment::raw("C("),
7031                    WebUIFragment::signal("title", false),
7032                    WebUIFragment::raw(")-"),
7033                    WebUIFragment::signal("cExtra", false),
7034                ],
7035                contains_boundary: false,
7036            },
7037        );
7038        fragments.insert(
7039            "child-component".to_string(),
7040            FragmentList {
7041                fragments: vec![
7042                    WebUIFragment::raw("<grandchild-component"),
7043                    WebUIFragment {
7044                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7045                            WebUIFragmentAttribute {
7046                                name: "title".into(),
7047                                value: "title".into(),
7048                                attr_start: true,
7049                                ..Default::default()
7050                            },
7051                        )),
7052                    },
7053                    WebUIFragment::raw(">"),
7054                    WebUIFragment::component("grandchild-component"),
7055                    WebUIFragment::raw("</grandchild-component>"),
7056                ],
7057
7058                contains_boundary: false,
7059            },
7060        );
7061        fragments.insert(
7062            "grandchild-component".to_string(),
7063            FragmentList {
7064                fragments: vec![
7065                    WebUIFragment::raw("<span>"),
7066                    WebUIFragment::signal("title", false),
7067                    WebUIFragment::raw("</span>"),
7068                ],
7069                contains_boundary: false,
7070            },
7071        );
7072        let protocol = WebUIProtocol::new(fragments);
7073        let state = test_json!({"p": "<p>", "cExtra": "x&y"});
7074        let mut writer = TestWriter::new();
7075        handle(
7076            &protocol,
7077            &state,
7078            &RenderOptions::new("index.html", "/"),
7079            &mut writer,
7080        )
7081        .unwrap();
7082        assert_eq!(
7083            writer.get_content(),
7084            "<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>"
7085        );
7086    }
7087
7088    #[test]
7089    fn test_for_loop_component_attr() {
7090        let mut fragments = HashMap::new();
7091        fragments.insert(
7092            "index.html".to_string(),
7093            FragmentList {
7094                fragments: vec![
7095                    WebUIFragment::raw("<parent-component"),
7096                    WebUIFragment {
7097                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7098                            WebUIFragmentAttribute {
7099                                name: "title".into(),
7100                                template: "parent-title-loop".into(),
7101                                attr_start: true,
7102                                ..Default::default()
7103                            },
7104                        )),
7105                    },
7106                    WebUIFragment::raw(">"),
7107                    WebUIFragment::component("parent-component"),
7108                    WebUIFragment::raw("</parent-component>"),
7109                ],
7110
7111                contains_boundary: false,
7112            },
7113        );
7114        fragments.insert(
7115            "parent-title-loop".to_string(),
7116            FragmentList {
7117                fragments: vec![
7118                    WebUIFragment::raw("Parent:"),
7119                    WebUIFragment::signal("who", false),
7120                ],
7121                contains_boundary: false,
7122            },
7123        );
7124        fragments.insert(
7125            "parent-component".to_string(),
7126            FragmentList {
7127                fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
7128                contains_boundary: false,
7129            },
7130        );
7131        fragments.insert(
7132            "child-loop".to_string(),
7133            FragmentList {
7134                fragments: vec![
7135                    WebUIFragment::raw("<child-component"),
7136                    WebUIFragment {
7137                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7138                            WebUIFragmentAttribute {
7139                                name: "title".into(),
7140                                template: "child-title-loop".into(),
7141                                attr_start: true,
7142                                ..Default::default()
7143                            },
7144                        )),
7145                    },
7146                    WebUIFragment::raw(">"),
7147                    WebUIFragment::component("child-component"),
7148                    WebUIFragment::raw("</child-component>"),
7149                ],
7150
7151                contains_boundary: false,
7152            },
7153        );
7154        fragments.insert(
7155            "child-title-loop".to_string(),
7156            FragmentList {
7157                fragments: vec![
7158                    WebUIFragment::raw("Hi "),
7159                    WebUIFragment::signal("item.name", false),
7160                    WebUIFragment::raw(" / "),
7161                    WebUIFragment::signal("title", false),
7162                ],
7163                contains_boundary: false,
7164            },
7165        );
7166        fragments.insert(
7167            "child-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!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
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=\"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>"
7190        );
7191    }
7192
7193    #[test]
7194    fn test_multiple_template_attrs() {
7195        let mut fragments = HashMap::new();
7196        fragments.insert(
7197            "index.html".to_string(),
7198            FragmentList {
7199                fragments: vec![
7200                    WebUIFragment::raw("<my-component"),
7201                    WebUIFragment {
7202                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7203                            WebUIFragmentAttribute {
7204                                name: "title".into(),
7205                                template: "attr-title".into(),
7206                                attr_start: true,
7207                                ..Default::default()
7208                            },
7209                        )),
7210                    },
7211                    WebUIFragment {
7212                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7213                            WebUIFragmentAttribute {
7214                                name: "data-title".into(),
7215                                template: "attr-data-title".into(),
7216                                attr_start: false,
7217                                ..Default::default()
7218                            },
7219                        )),
7220                    },
7221                    WebUIFragment {
7222                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7223                            WebUIFragmentAttribute {
7224                                name: "aria-label".into(),
7225                                template: "attr-aria-label".into(),
7226                                attr_start: false,
7227                                ..Default::default()
7228                            },
7229                        )),
7230                    },
7231                    WebUIFragment::raw(">"),
7232                    WebUIFragment::component("my-component"),
7233                    WebUIFragment::raw("</my-component>"),
7234                ],
7235
7236                contains_boundary: false,
7237            },
7238        );
7239        fragments.insert(
7240            "attr-title".to_string(),
7241            FragmentList {
7242                fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
7243                contains_boundary: false,
7244            },
7245        );
7246        fragments.insert(
7247            "attr-data-title".to_string(),
7248            FragmentList {
7249                fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
7250                contains_boundary: false,
7251            },
7252        );
7253        fragments.insert(
7254            "attr-aria-label".to_string(),
7255            FragmentList {
7256                fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
7257                contains_boundary: false,
7258            },
7259        );
7260        fragments.insert(
7261            "my-component".to_string(),
7262            FragmentList {
7263                fragments: vec![
7264                    WebUIFragment::raw("<span>"),
7265                    WebUIFragment::signal("title", false),
7266                    WebUIFragment::raw("|"),
7267                    WebUIFragment::signal("dataTitle", false),
7268                    WebUIFragment::raw("|"),
7269                    WebUIFragment::signal("ariaLabel", false),
7270                    WebUIFragment::raw("</span>"),
7271                ],
7272                contains_boundary: false,
7273            },
7274        );
7275        let protocol = WebUIProtocol::new(fragments);
7276        let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
7277        let mut writer = TestWriter::new();
7278        handle(
7279            &protocol,
7280            &state,
7281            &RenderOptions::new("index.html", "/"),
7282            &mut writer,
7283        )
7284        .unwrap();
7285        assert_eq!(
7286            writer.get_content(),
7287            "<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>"
7288        );
7289    }
7290
7291    #[test]
7292    fn test_attr_priority_over_global() {
7293        let mut fragments = HashMap::new();
7294        fragments.insert(
7295            "index.html".to_string(),
7296            FragmentList {
7297                fragments: vec![
7298                    WebUIFragment::raw("<my-component"),
7299                    WebUIFragment {
7300                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7301                            WebUIFragmentAttribute {
7302                                name: "title".into(),
7303                                value: "Attribute Title".into(),
7304                                raw_value: true,
7305                                attr_start: true,
7306                                ..Default::default()
7307                            },
7308                        )),
7309                    },
7310                    WebUIFragment::raw(">"),
7311                    WebUIFragment::component("my-component"),
7312                    WebUIFragment::raw("</my-component>"),
7313                ],
7314
7315                contains_boundary: false,
7316            },
7317        );
7318        fragments.insert(
7319            "my-component".to_string(),
7320            FragmentList {
7321                fragments: vec![
7322                    WebUIFragment::raw("<span>"),
7323                    WebUIFragment::signal("title", false),
7324                    WebUIFragment::raw("</span>"),
7325                ],
7326                contains_boundary: false,
7327            },
7328        );
7329        let protocol = WebUIProtocol::new(fragments);
7330        let state = test_json!({"title": "Global Title"});
7331        let mut writer = TestWriter::new();
7332        handle(
7333            &protocol,
7334            &state,
7335            &RenderOptions::new("index.html", "/"),
7336            &mut writer,
7337        )
7338        .unwrap();
7339        assert_eq!(
7340            writer.get_content(),
7341            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
7342        );
7343    }
7344
7345    #[test]
7346    fn test_attr_priority_over_local_and_global() {
7347        let mut fragments = HashMap::new();
7348        fragments.insert(
7349            "index.html".to_string(),
7350            FragmentList {
7351                fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
7352                contains_boundary: false,
7353            },
7354        );
7355        fragments.insert(
7356            "loop".to_string(),
7357            FragmentList {
7358                fragments: vec![
7359                    WebUIFragment::raw("<my-component"),
7360                    WebUIFragment {
7361                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7362                            WebUIFragmentAttribute {
7363                                name: "title".into(),
7364                                value: "Attribute Title".into(),
7365                                raw_value: true,
7366                                attr_start: true,
7367                                ..Default::default()
7368                            },
7369                        )),
7370                    },
7371                    WebUIFragment::raw(">"),
7372                    WebUIFragment::component("my-component"),
7373                    WebUIFragment::raw("</my-component>"),
7374                ],
7375
7376                contains_boundary: false,
7377            },
7378        );
7379        fragments.insert(
7380            "my-component".to_string(),
7381            FragmentList {
7382                fragments: vec![
7383                    WebUIFragment::raw("<span>"),
7384                    WebUIFragment::signal("title", false),
7385                    WebUIFragment::raw("</span>"),
7386                ],
7387                contains_boundary: false,
7388            },
7389        );
7390        let protocol = WebUIProtocol::new(fragments);
7391        let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
7392        let mut writer = TestWriter::new();
7393        handle(
7394            &protocol,
7395            &state,
7396            &RenderOptions::new("index.html", "/"),
7397            &mut writer,
7398        )
7399        .unwrap();
7400        assert_eq!(
7401            writer.get_content(),
7402            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
7403        );
7404    }
7405
7406    #[test]
7407    fn test_boolean_attr_first_component_attr() {
7408        let mut fragments = HashMap::new();
7409        fragments.insert(
7410            "index.html".to_string(),
7411            FragmentList {
7412                fragments: vec![
7413                    WebUIFragment::raw("<my-component"),
7414                    WebUIFragment {
7415                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7416                            WebUIFragmentAttribute {
7417                                name: "disabled".into(),
7418                                attr_start: true,
7419                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7420                                ..Default::default()
7421                            },
7422                        )),
7423                    },
7424                    WebUIFragment {
7425                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7426                            WebUIFragmentAttribute {
7427                                name: "label".into(),
7428                                value: "Component Label".into(),
7429                                raw_value: true,
7430                                attr_start: false,
7431                                ..Default::default()
7432                            },
7433                        )),
7434                    },
7435                    WebUIFragment::raw(">"),
7436                    WebUIFragment::component("my-component"),
7437                    WebUIFragment::raw("</my-component>"),
7438                ],
7439
7440                contains_boundary: false,
7441            },
7442        );
7443        fragments.insert(
7444            "my-component".to_string(),
7445            FragmentList {
7446                fragments: vec![
7447                    WebUIFragment::if_cond(
7448                        ConditionExpr::identifier("disabled"),
7449                        "disabledTemplate",
7450                    ),
7451                    WebUIFragment::raw("<span>"),
7452                    WebUIFragment::signal("label", false),
7453                    WebUIFragment::raw("</span>"),
7454                ],
7455                contains_boundary: false,
7456            },
7457        );
7458        fragments.insert(
7459            "disabledTemplate".to_string(),
7460            FragmentList {
7461                fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
7462                contains_boundary: false,
7463            },
7464        );
7465        let protocol = WebUIProtocol::new(fragments);
7466        let state = test_json!({"isDisabled": true});
7467        let mut writer = TestWriter::new();
7468        handle(
7469            &protocol,
7470            &state,
7471            &RenderOptions::new("index.html", "/"),
7472            &mut writer,
7473        )
7474        .unwrap();
7475        assert_eq!(
7476            writer.get_content(),
7477            "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
7478        );
7479    }
7480
7481    #[test]
7482    fn test_hyphenated_attr_camelcase() {
7483        let mut fragments = HashMap::new();
7484        fragments.insert(
7485            "index.html".to_string(),
7486            FragmentList {
7487                fragments: vec![
7488                    WebUIFragment::raw("<my-component"),
7489                    WebUIFragment {
7490                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7491                            WebUIFragmentAttribute {
7492                                name: "key-hyphen".into(),
7493                                value: "Local Value".into(),
7494                                raw_value: true,
7495                                attr_start: true,
7496                                ..Default::default()
7497                            },
7498                        )),
7499                    },
7500                    WebUIFragment::raw(">"),
7501                    WebUIFragment::component("my-component"),
7502                    WebUIFragment::raw("</my-component>"),
7503                ],
7504
7505                contains_boundary: false,
7506            },
7507        );
7508        fragments.insert(
7509            "my-component".to_string(),
7510            FragmentList {
7511                fragments: vec![
7512                    WebUIFragment::raw("<span>"),
7513                    WebUIFragment::signal("keyHyphen", false),
7514                    WebUIFragment::raw("</span>"),
7515                ],
7516                contains_boundary: false,
7517            },
7518        );
7519        let protocol = WebUIProtocol::new(fragments);
7520        let state = test_json!({"keyHyphen": "Global Value"});
7521        let mut writer = TestWriter::new();
7522        handle(
7523            &protocol,
7524            &state,
7525            &RenderOptions::new("index.html", "/"),
7526            &mut writer,
7527        )
7528        .unwrap();
7529        assert_eq!(
7530            writer.get_content(),
7531            "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
7532        );
7533    }
7534
7535    #[test]
7536    fn test_skipped_component_attrs() {
7537        // Skipped attributes: class, style, role, data-*, aria-*
7538        // Plus framework-specific prefixes/names that the parser marks with attr_skip.
7539        // These render on the HTML element but are NOT passed into component attribute state.
7540        let mut fragments = HashMap::new();
7541        fragments.insert(
7542            "index.html".to_string(),
7543            FragmentList {
7544                fragments: vec![
7545                    WebUIFragment::raw("<test-component"),
7546                    // Skipped: class
7547                    WebUIFragment {
7548                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7549                            WebUIFragmentAttribute {
7550                                name: "class".into(),
7551                                value: "skippedClass".into(),
7552                                attr_start: true,
7553                                attr_skip: true,
7554                                ..Default::default()
7555                            },
7556                        )),
7557                    },
7558                    // Skipped: style
7559                    WebUIFragment {
7560                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7561                            WebUIFragmentAttribute {
7562                                name: "style".into(),
7563                                value: "skippedStyle".into(),
7564                                attr_skip: true,
7565                                ..Default::default()
7566                            },
7567                        )),
7568                    },
7569                    // Skipped: role
7570                    WebUIFragment {
7571                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7572                            WebUIFragmentAttribute {
7573                                name: "role".into(),
7574                                value: "skippedRole".into(),
7575                                attr_skip: true,
7576                                ..Default::default()
7577                            },
7578                        )),
7579                    },
7580                    // Skipped: data-testid (data-* prefix)
7581                    WebUIFragment {
7582                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7583                            WebUIFragmentAttribute {
7584                                name: "data-testid".into(),
7585                                value: "skippedDataTestid".into(),
7586                                attr_skip: true,
7587                                ..Default::default()
7588                            },
7589                        )),
7590                    },
7591                    // Skipped: aria-label (aria-* prefix)
7592                    WebUIFragment {
7593                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7594                            WebUIFragmentAttribute {
7595                                name: "aria-label".into(),
7596                                value: "skippedAriaLabel".into(),
7597                                attr_skip: true,
7598                                ..Default::default()
7599                            },
7600                        )),
7601                    },
7602                    // NOT skipped: title
7603                    WebUIFragment {
7604                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7605                            WebUIFragmentAttribute {
7606                                name: "title".into(),
7607                                value: "title".into(),
7608                                ..Default::default()
7609                            },
7610                        )),
7611                    },
7612                    WebUIFragment::raw(">"),
7613                    WebUIFragment::component("test-component"),
7614                    WebUIFragment::raw("</test-component>"),
7615                ],
7616
7617                contains_boundary: false,
7618            },
7619        );
7620        fragments.insert(
7621            "test-component".to_string(),
7622            FragmentList {
7623                fragments: vec![
7624                    WebUIFragment::raw("<span>"),
7625                    WebUIFragment::signal("title", false),
7626                    WebUIFragment::raw("-"),
7627                    WebUIFragment::signal("class", false),
7628                    WebUIFragment::raw("-"),
7629                    WebUIFragment::signal("style", false),
7630                    WebUIFragment::raw("-"),
7631                    WebUIFragment::signal("role", false),
7632                    WebUIFragment::raw("-"),
7633                    WebUIFragment::signal("dataTestid", false),
7634                    WebUIFragment::raw("-"),
7635                    WebUIFragment::signal("ariaLabel", false),
7636                    WebUIFragment::raw("</span>"),
7637                ],
7638                contains_boundary: false,
7639            },
7640        );
7641        let protocol = WebUIProtocol::new(fragments);
7642        let state = test_json!({
7643            "title": "Hello",
7644            "skippedClass": "my-class",
7645            "skippedStyle": "color:red",
7646            "skippedRole": "button",
7647            "skippedDataTestid": "test-id",
7648            "skippedAriaLabel": "label-text"
7649        });
7650        let mut writer = TestWriter::new();
7651        handle(
7652            &protocol,
7653            &state,
7654            &RenderOptions::new("index.html", "/"),
7655            &mut writer,
7656        )
7657        .unwrap();
7658        // Skipped attrs render on the element but their values are NOT accessible inside the component.
7659        // The component's signals for skipped attrs resolve to empty strings.
7660        // Only "title" (non-skipped) is accessible.
7661        assert_eq!(
7662            writer.get_content(),
7663            "<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>"
7664        );
7665    }
7666
7667    // ── GROUP 7: Attribute Inheritance ─────────────────────────────────
7668
7669    #[test]
7670    fn test_attr_inherit_parent_to_child() {
7671        let mut fragments = HashMap::new();
7672        fragments.insert(
7673            "index.html".to_string(),
7674            FragmentList {
7675                fragments: vec![
7676                    WebUIFragment::raw("<parent-component"),
7677                    WebUIFragment {
7678                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7679                            WebUIFragmentAttribute {
7680                                name: "title".into(),
7681                                value: "Parent Title".into(),
7682                                raw_value: true,
7683                                attr_start: true,
7684                                ..Default::default()
7685                            },
7686                        )),
7687                    },
7688                    WebUIFragment::raw(">"),
7689                    WebUIFragment::component("parent-component"),
7690                    WebUIFragment::raw("</parent-component>"),
7691                ],
7692
7693                contains_boundary: false,
7694            },
7695        );
7696        fragments.insert(
7697            "parent-component".to_string(),
7698            FragmentList {
7699                fragments: vec![
7700                    WebUIFragment::raw("<h1>"),
7701                    WebUIFragment::signal("title", false),
7702                    WebUIFragment::raw("</h1><child-component"),
7703                    WebUIFragment {
7704                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7705                            WebUIFragmentAttribute {
7706                                name: "title".into(),
7707                                value: "title".into(),
7708                                attr_start: true,
7709                                ..Default::default()
7710                            },
7711                        )),
7712                    },
7713                    WebUIFragment::raw(">"),
7714                    WebUIFragment::component("child-component"),
7715                    WebUIFragment::raw("</child-component>"),
7716                ],
7717
7718                contains_boundary: false,
7719            },
7720        );
7721        fragments.insert(
7722            "child-component".to_string(),
7723            FragmentList {
7724                fragments: vec![
7725                    WebUIFragment::raw("<h2>"),
7726                    WebUIFragment::signal("title", false),
7727                    WebUIFragment::raw("</h2>"),
7728                ],
7729                contains_boundary: false,
7730            },
7731        );
7732        let protocol = WebUIProtocol::new(fragments);
7733        let state = test_json!({});
7734        let mut writer = TestWriter::new();
7735        handle(
7736            &protocol,
7737            &state,
7738            &RenderOptions::new("index.html", "/"),
7739            &mut writer,
7740        )
7741        .unwrap();
7742        assert_eq!(
7743            writer.get_content(),
7744            "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
7745        );
7746    }
7747
7748    #[test]
7749    fn test_attr_inherit_deep() {
7750        let mut fragments = HashMap::new();
7751        fragments.insert(
7752            "index.html".to_string(),
7753            FragmentList {
7754                fragments: vec![
7755                    WebUIFragment::raw("<parent-component"),
7756                    WebUIFragment {
7757                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7758                            WebUIFragmentAttribute {
7759                                name: "title".into(),
7760                                value: "Parent Title".into(),
7761                                raw_value: true,
7762                                attr_start: true,
7763                                ..Default::default()
7764                            },
7765                        )),
7766                    },
7767                    WebUIFragment::raw(">"),
7768                    WebUIFragment::component("parent-component"),
7769                    WebUIFragment::raw("</parent-component>"),
7770                ],
7771
7772                contains_boundary: false,
7773            },
7774        );
7775        fragments.insert(
7776            "parent-component".to_string(),
7777            FragmentList {
7778                fragments: vec![
7779                    WebUIFragment::raw("<child-component"),
7780                    WebUIFragment {
7781                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7782                            WebUIFragmentAttribute {
7783                                name: "title".into(),
7784                                value: "Child Title".into(),
7785                                raw_value: true,
7786                                attr_start: true,
7787                                ..Default::default()
7788                            },
7789                        )),
7790                    },
7791                    WebUIFragment::raw(">"),
7792                    WebUIFragment::component("child-component"),
7793                    WebUIFragment::raw("</child-component>"),
7794                ],
7795
7796                contains_boundary: false,
7797            },
7798        );
7799        fragments.insert(
7800            "child-component".to_string(),
7801            FragmentList {
7802                fragments: vec![
7803                    WebUIFragment::raw("<grandchild-component"),
7804                    WebUIFragment {
7805                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7806                            WebUIFragmentAttribute {
7807                                name: "title".into(),
7808                                value: "title".into(),
7809                                attr_start: true,
7810                                ..Default::default()
7811                            },
7812                        )),
7813                    },
7814                    WebUIFragment::raw(">"),
7815                    WebUIFragment::component("grandchild-component"),
7816                    WebUIFragment::raw("</grandchild-component>"),
7817                ],
7818
7819                contains_boundary: false,
7820            },
7821        );
7822        fragments.insert(
7823            "grandchild-component".to_string(),
7824            FragmentList {
7825                fragments: vec![
7826                    WebUIFragment::raw("<h3>"),
7827                    WebUIFragment::signal("title", false),
7828                    WebUIFragment::raw("</h3>"),
7829                ],
7830                contains_boundary: false,
7831            },
7832        );
7833        let protocol = WebUIProtocol::new(fragments);
7834        let state = test_json!({});
7835        let mut writer = TestWriter::new();
7836        handle(
7837            &protocol,
7838            &state,
7839            &RenderOptions::new("index.html", "/"),
7840            &mut writer,
7841        )
7842        .unwrap();
7843        assert_eq!(
7844            writer.get_content(),
7845            "<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>"
7846        );
7847    }
7848
7849    #[test]
7850    fn test_complex_attr_access() {
7851        let mut fragments = HashMap::new();
7852        fragments.insert(
7853            "index.html".to_string(),
7854            FragmentList {
7855                fragments: vec![
7856                    WebUIFragment::raw("<my-component"),
7857                    WebUIFragment {
7858                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7859                            WebUIFragmentAttribute {
7860                                name: ":item".into(),
7861                                value: "complexItem".into(),
7862                                attr_start: true,
7863                                complex: true,
7864                                ..Default::default()
7865                            },
7866                        )),
7867                    },
7868                    WebUIFragment::raw(">"),
7869                    WebUIFragment::component("my-component"),
7870                    WebUIFragment::raw("</my-component>"),
7871                ],
7872
7873                contains_boundary: false,
7874            },
7875        );
7876        fragments.insert(
7877            "my-component".to_string(),
7878            FragmentList {
7879                fragments: vec![
7880                    WebUIFragment::raw("<span>"),
7881                    WebUIFragment::signal("item.foo", false),
7882                    WebUIFragment::raw("</span><p>"),
7883                    WebUIFragment::signal("item.bar", false),
7884                    WebUIFragment::raw("</p>"),
7885                ],
7886                contains_boundary: false,
7887            },
7888        );
7889        let protocol = WebUIProtocol::new(fragments);
7890        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
7891        let mut writer = TestWriter::new();
7892        handle(
7893            &protocol,
7894            &state,
7895            &RenderOptions::new("index.html", "/"),
7896            &mut writer,
7897        )
7898        .unwrap();
7899        assert_eq!(
7900            writer.get_content(),
7901            "<my-component><span>1</span><p>true</p></my-component>"
7902        );
7903    }
7904
7905    #[test]
7906    fn test_complex_attr_for_loop() {
7907        let mut fragments = HashMap::new();
7908        fragments.insert(
7909            "index.html".to_string(),
7910            FragmentList {
7911                fragments: vec![WebUIFragment::for_loop(
7912                    "item",
7913                    "list.items",
7914                    "listTemplate",
7915                )],
7916                contains_boundary: false,
7917            },
7918        );
7919        fragments.insert(
7920            "listTemplate".to_string(),
7921            FragmentList {
7922                fragments: vec![
7923                    WebUIFragment {
7924                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7925                            WebUIFragmentAttribute {
7926                                name: ":item".into(),
7927                                value: "item".into(),
7928                                attr_start: true,
7929                                complex: true,
7930                                ..Default::default()
7931                            },
7932                        )),
7933                    },
7934                    WebUIFragment::component("item_component"),
7935                ],
7936
7937                contains_boundary: false,
7938            },
7939        );
7940        fragments.insert(
7941            "item_component".to_string(),
7942            FragmentList {
7943                fragments: vec![
7944                    WebUIFragment::raw("<span>"),
7945                    WebUIFragment::signal("item.name", false),
7946                    WebUIFragment::raw("</span>"),
7947                ],
7948                contains_boundary: false,
7949            },
7950        );
7951        let protocol = WebUIProtocol::new(fragments);
7952        let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
7953        let mut writer = TestWriter::new();
7954        handle(
7955            &protocol,
7956            &state,
7957            &RenderOptions::new("index.html", "/"),
7958            &mut writer,
7959        )
7960        .unwrap();
7961        assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
7962    }
7963
7964    #[test]
7965    fn test_complex_attr_nested_for() {
7966        let mut fragments = HashMap::new();
7967        fragments.insert(
7968            "index.html".to_string(),
7969            FragmentList {
7970                fragments: vec![WebUIFragment::for_loop(
7971                    "outer",
7972                    "data.outer",
7973                    "outerTemplate",
7974                )],
7975                contains_boundary: false,
7976            },
7977        );
7978        fragments.insert(
7979            "outerTemplate".to_string(),
7980            FragmentList {
7981                fragments: vec![WebUIFragment::for_loop(
7982                    "middle",
7983                    "outer.middle",
7984                    "middleTemplate",
7985                )],
7986                contains_boundary: false,
7987            },
7988        );
7989        fragments.insert(
7990            "middleTemplate".to_string(),
7991            FragmentList {
7992                fragments: vec![WebUIFragment::for_loop(
7993                    "inner",
7994                    "middle.inner",
7995                    "innerTemplate",
7996                )],
7997                contains_boundary: false,
7998            },
7999        );
8000        fragments.insert(
8001            "innerTemplate".to_string(),
8002            FragmentList {
8003                fragments: vec![
8004                    WebUIFragment::raw("<card"),
8005                    WebUIFragment {
8006                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8007                            WebUIFragmentAttribute {
8008                                name: ":outer".into(),
8009                                value: "outer".into(),
8010                                attr_start: true,
8011                                complex: true,
8012                                ..Default::default()
8013                            },
8014                        )),
8015                    },
8016                    WebUIFragment {
8017                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8018                            WebUIFragmentAttribute {
8019                                name: ":middle".into(),
8020                                value: "middle".into(),
8021                                attr_start: false,
8022                                complex: true,
8023                                ..Default::default()
8024                            },
8025                        )),
8026                    },
8027                    WebUIFragment {
8028                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8029                            WebUIFragmentAttribute {
8030                                name: ":inner".into(),
8031                                value: "inner".into(),
8032                                attr_start: false,
8033                                complex: true,
8034                                ..Default::default()
8035                            },
8036                        )),
8037                    },
8038                    WebUIFragment::raw(">"),
8039                    WebUIFragment::component("card_component"),
8040                    WebUIFragment::raw("</card>"),
8041                ],
8042
8043                contains_boundary: false,
8044            },
8045        );
8046        fragments.insert(
8047            "card_component".to_string(),
8048            FragmentList {
8049                fragments: vec![
8050                    WebUIFragment::raw("<p>"),
8051                    WebUIFragment::signal("outer.label", false),
8052                    WebUIFragment::raw(" / "),
8053                    WebUIFragment::signal("middle.label", false),
8054                    WebUIFragment::raw(" / "),
8055                    WebUIFragment::signal("inner.label", false),
8056                    WebUIFragment::raw("</p>"),
8057                ],
8058                contains_boundary: false,
8059            },
8060        );
8061        let protocol = WebUIProtocol::new(fragments);
8062        let state = test_json!({"data": {"outer": [
8063            {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
8064            {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
8065        ]}});
8066        let mut writer = TestWriter::new();
8067        handle(
8068            &protocol,
8069            &state,
8070            &RenderOptions::new("index.html", "/"),
8071            &mut writer,
8072        )
8073        .unwrap();
8074        assert_eq!(
8075            writer.get_content(),
8076            "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
8077        );
8078    }
8079
8080    // ── GROUP 8: Boolean Component State ──────────────────────────────
8081
8082    #[test]
8083    fn test_bool_component_state_true() {
8084        let mut fragments = HashMap::new();
8085        fragments.insert(
8086            "index.html".to_string(),
8087            FragmentList {
8088                fragments: vec![
8089                    WebUIFragment::raw("<my-component"),
8090                    WebUIFragment {
8091                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8092                            WebUIFragmentAttribute {
8093                                name: "disabled".into(),
8094                                attr_start: true,
8095                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8096                                ..Default::default()
8097                            },
8098                        )),
8099                    },
8100                    WebUIFragment::raw(">"),
8101                    WebUIFragment::component("my-component"),
8102                    WebUIFragment::raw("</my-component>"),
8103                ],
8104
8105                contains_boundary: false,
8106            },
8107        );
8108        fragments.insert(
8109            "my-component".to_string(),
8110            FragmentList {
8111                fragments: vec![
8112                    WebUIFragment::if_cond(
8113                        ConditionExpr::identifier("disabled"),
8114                        "disabledTemplate",
8115                    ),
8116                    WebUIFragment::if_cond(
8117                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8118                        "enabledTemplate",
8119                    ),
8120                ],
8121                contains_boundary: false,
8122            },
8123        );
8124        fragments.insert(
8125            "disabledTemplate".to_string(),
8126            FragmentList {
8127                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
8128                contains_boundary: false,
8129            },
8130        );
8131        fragments.insert(
8132            "enabledTemplate".to_string(),
8133            FragmentList {
8134                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
8135                contains_boundary: false,
8136            },
8137        );
8138        let protocol = WebUIProtocol::new(fragments);
8139        let state = test_json!({"isDisabled": true});
8140        let mut writer = TestWriter::new();
8141        handle(
8142            &protocol,
8143            &state,
8144            &RenderOptions::new("index.html", "/"),
8145            &mut writer,
8146        )
8147        .unwrap();
8148        assert_eq!(
8149            writer.get_content(),
8150            "<my-component disabled><span>Disabled</span></my-component>"
8151        );
8152    }
8153
8154    #[test]
8155    fn test_bool_component_state_false() {
8156        let mut fragments = HashMap::new();
8157        fragments.insert(
8158            "index.html".to_string(),
8159            FragmentList {
8160                fragments: vec![
8161                    WebUIFragment::raw("<my-component"),
8162                    WebUIFragment {
8163                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8164                            WebUIFragmentAttribute {
8165                                name: "disabled".into(),
8166                                attr_start: true,
8167                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8168                                ..Default::default()
8169                            },
8170                        )),
8171                    },
8172                    WebUIFragment::raw(">"),
8173                    WebUIFragment::component("my-component"),
8174                    WebUIFragment::raw("</my-component>"),
8175                ],
8176
8177                contains_boundary: false,
8178            },
8179        );
8180        fragments.insert(
8181            "my-component".to_string(),
8182            FragmentList {
8183                fragments: vec![
8184                    WebUIFragment::if_cond(
8185                        ConditionExpr::identifier("disabled"),
8186                        "disabledTemplate",
8187                    ),
8188                    WebUIFragment::if_cond(
8189                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8190                        "enabledTemplate",
8191                    ),
8192                ],
8193                contains_boundary: false,
8194            },
8195        );
8196        fragments.insert(
8197            "disabledTemplate".to_string(),
8198            FragmentList {
8199                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
8200                contains_boundary: false,
8201            },
8202        );
8203        fragments.insert(
8204            "enabledTemplate".to_string(),
8205            FragmentList {
8206                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
8207                contains_boundary: false,
8208            },
8209        );
8210        let protocol = WebUIProtocol::new(fragments);
8211        let state = test_json!({"isDisabled": false});
8212        let mut writer = TestWriter::new();
8213        handle(
8214            &protocol,
8215            &state,
8216            &RenderOptions::new("index.html", "/"),
8217            &mut writer,
8218        )
8219        .unwrap();
8220        assert_eq!(
8221            writer.get_content(),
8222            "<my-component><span>Enabled</span></my-component>"
8223        );
8224    }
8225
8226    #[test]
8227    fn test_bool_component_state_forward() {
8228        let mut fragments = HashMap::new();
8229        fragments.insert(
8230            "index.html".to_string(),
8231            FragmentList {
8232                fragments: vec![
8233                    WebUIFragment::raw("<parent-component"),
8234                    WebUIFragment {
8235                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8236                            WebUIFragmentAttribute {
8237                                name: "disabled".into(),
8238                                attr_start: true,
8239                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8240                                ..Default::default()
8241                            },
8242                        )),
8243                    },
8244                    WebUIFragment::raw(">"),
8245                    WebUIFragment::component("parent-component"),
8246                    WebUIFragment::raw("</parent-component>"),
8247                ],
8248
8249                contains_boundary: false,
8250            },
8251        );
8252        fragments.insert(
8253            "parent-component".to_string(),
8254            FragmentList {
8255                fragments: vec![
8256                    WebUIFragment::if_cond(
8257                        ConditionExpr::identifier("disabled"),
8258                        "parentDisabledTemplate",
8259                    ),
8260                    WebUIFragment::raw("<child-component"),
8261                    WebUIFragment {
8262                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8263                            WebUIFragmentAttribute {
8264                                name: "disabled".into(),
8265                                attr_start: true,
8266                                condition_tree: Some(ConditionExpr::identifier("disabled")),
8267                                ..Default::default()
8268                            },
8269                        )),
8270                    },
8271                    WebUIFragment::raw(">"),
8272                    WebUIFragment::component("child-component"),
8273                    WebUIFragment::raw("</child-component>"),
8274                ],
8275
8276                contains_boundary: false,
8277            },
8278        );
8279        fragments.insert(
8280            "parentDisabledTemplate".to_string(),
8281            FragmentList {
8282                fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
8283                contains_boundary: false,
8284            },
8285        );
8286        fragments.insert(
8287            "child-component".to_string(),
8288            FragmentList {
8289                fragments: vec![
8290                    WebUIFragment::if_cond(
8291                        ConditionExpr::identifier("disabled"),
8292                        "childDisabledTemplate",
8293                    ),
8294                    WebUIFragment::if_cond(
8295                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8296                        "childEnabledTemplate",
8297                    ),
8298                ],
8299                contains_boundary: false,
8300            },
8301        );
8302        fragments.insert(
8303            "childDisabledTemplate".to_string(),
8304            FragmentList {
8305                fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
8306                contains_boundary: false,
8307            },
8308        );
8309        fragments.insert(
8310            "childEnabledTemplate".to_string(),
8311            FragmentList {
8312                fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
8313                contains_boundary: false,
8314            },
8315        );
8316
8317        // Test case 1: isDisabled = true
8318        {
8319            let protocol = WebUIProtocol::new(fragments.clone());
8320            let state = test_json!({"isDisabled": true});
8321            let mut writer = TestWriter::new();
8322            handle(
8323                &protocol,
8324                &state,
8325                &RenderOptions::new("index.html", "/"),
8326                &mut writer,
8327            )
8328            .unwrap();
8329            assert_eq!(
8330                writer.get_content(),
8331                "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
8332            );
8333        }
8334
8335        // Test case 2: isDisabled = false
8336        {
8337            let protocol = WebUIProtocol::new(fragments.clone());
8338            let state = test_json!({"isDisabled": false});
8339            let mut writer = TestWriter::new();
8340            handle(
8341                &protocol,
8342                &state,
8343                &RenderOptions::new("index.html", "/"),
8344                &mut writer,
8345            )
8346            .unwrap();
8347            assert_eq!(
8348                writer.get_content(),
8349                "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
8350            );
8351        }
8352    }
8353
8354    // ── GROUP 9: Hydration (SKIP) ─────────────────────────────────────
8355
8356    // TODO: test_hydration - requires FAST handler plugin integration; see plugin/fast.rs
8357
8358    // ── Component tests ──────────────────────────────────────────────
8359
8360    #[test]
8361    fn test_component_with_template() {
8362        let mut fragments = HashMap::new();
8363        fragments.insert(
8364            "index.html".to_string(),
8365            FragmentList {
8366                fragments: vec![
8367                    WebUIFragment::raw("<custom-element>"),
8368                    WebUIFragment::component("custom-element"),
8369                    WebUIFragment::raw("</custom-element>"),
8370                ],
8371                contains_boundary: false,
8372            },
8373        );
8374        fragments.insert(
8375            "custom-element".to_string(),
8376            FragmentList {
8377                fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
8378                contains_boundary: false,
8379            },
8380        );
8381        let protocol = WebUIProtocol::new(fragments);
8382        let state = test_json!({});
8383        let mut writer = TestWriter::new();
8384        handle(
8385            &protocol,
8386            &state,
8387            &RenderOptions::new("index.html", "/"),
8388            &mut writer,
8389        )
8390        .unwrap();
8391        assert_eq!(
8392            writer.get_content(),
8393            "<custom-element><div>Custom Element</div></custom-element>"
8394        );
8395        assert!(writer.is_ended());
8396    }
8397
8398    #[test]
8399    fn test_component_with_slots() {
8400        let mut fragments = HashMap::new();
8401        fragments.insert(
8402            "index.html".to_string(),
8403            FragmentList {
8404                fragments: vec![
8405                    WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
8406                    WebUIFragment::component("custom-element"),
8407                    WebUIFragment::raw("Hello World</custom-element>"),
8408                ],
8409                contains_boundary: false,
8410            },
8411        );
8412        fragments.insert(
8413            "custom-element".to_string(),
8414            FragmentList {
8415                fragments: vec![WebUIFragment::raw("<slot></slot>")],
8416                contains_boundary: false,
8417            },
8418        );
8419        let protocol = WebUIProtocol::new(fragments);
8420        let state = test_json!({});
8421        let mut writer = TestWriter::new();
8422        handle(
8423            &protocol,
8424            &state,
8425            &RenderOptions::new("index.html", "/"),
8426            &mut writer,
8427        )
8428        .unwrap();
8429        assert_eq!(
8430            writer.get_content(),
8431            "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
8432        );
8433        assert!(writer.is_ended());
8434    }
8435
8436    #[test]
8437    fn test_multiple_nested_components() {
8438        let mut fragments = HashMap::new();
8439        fragments.insert(
8440            "index.html".to_string(),
8441            FragmentList {
8442                fragments: vec![
8443                    WebUIFragment::raw("<div>"),
8444                    WebUIFragment::for_loop("item", "items", "templateRepeat"),
8445                    WebUIFragment::raw("</div>"),
8446                ],
8447                contains_boundary: false,
8448            },
8449        );
8450        fragments.insert(
8451            "custom-button".to_string(),
8452            FragmentList {
8453                fragments: vec![WebUIFragment::raw("<slot></slot>")],
8454                contains_boundary: false,
8455            },
8456        );
8457        fragments.insert(
8458            "custom-element".to_string(),
8459            FragmentList {
8460                fragments: vec![
8461                    WebUIFragment::raw("<custom-child>"),
8462                    WebUIFragment::component("custom-child"),
8463                    WebUIFragment::raw("</custom-child><slot></slot>"),
8464                ],
8465                contains_boundary: false,
8466            },
8467        );
8468        fragments.insert(
8469            "custom-child".to_string(),
8470            FragmentList {
8471                fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
8472                contains_boundary: false,
8473            },
8474        );
8475        fragments.insert(
8476            "templateRepeat".to_string(),
8477            FragmentList {
8478                fragments: vec![
8479                    WebUIFragment::raw("<custom-element>"),
8480                    WebUIFragment::component("custom-element"),
8481                    WebUIFragment::raw("<custom-button>"),
8482                    WebUIFragment::component("custom-button"),
8483                    WebUIFragment::raw("Ok</custom-button></custom-element>"),
8484                ],
8485                contains_boundary: false,
8486            },
8487        );
8488        let protocol = WebUIProtocol::new(fragments);
8489        let state = test_json!({"items": [{"name": "Item1"}]});
8490        let mut writer = TestWriter::new();
8491        handle(
8492            &protocol,
8493            &state,
8494            &RenderOptions::new("index.html", "/"),
8495            &mut writer,
8496        )
8497        .unwrap();
8498        assert_eq!(
8499            writer.get_content(),
8500            "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
8501        );
8502        assert!(writer.is_ended());
8503    }
8504
8505    // ── Conditional tests ────────────────────────────────────────────
8506
8507    #[test]
8508    fn test_if_with_binary_expression() {
8509        let mut fragments = HashMap::new();
8510        fragments.insert(
8511            "index.html".to_string(),
8512            FragmentList {
8513                fragments: vec![
8514                    WebUIFragment::raw("<div>"),
8515                    WebUIFragment::if_cond(
8516                        ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
8517                        "if-1",
8518                    ),
8519                    WebUIFragment::raw("</div>"),
8520                ],
8521                contains_boundary: false,
8522            },
8523        );
8524        fragments.insert(
8525            "if-1".to_string(),
8526            FragmentList {
8527                fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
8528                contains_boundary: false,
8529            },
8530        );
8531        let protocol = WebUIProtocol::new(fragments);
8532
8533        // True case: x = 10 > 5
8534        let state_true = test_json!({"x": 10});
8535        let mut writer_true = TestWriter::new();
8536        handle(
8537            &protocol,
8538            &state_true,
8539            &RenderOptions::new("index.html", "/"),
8540            &mut writer_true,
8541        )
8542        .unwrap();
8543        assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
8544
8545        // False case: x = 1 <= 5
8546        let state_false = test_json!({"x": 1});
8547        let mut writer_false = TestWriter::new();
8548        handle(
8549            &protocol,
8550            &state_false,
8551            &RenderOptions::new("index.html", "/"),
8552            &mut writer_false,
8553        )
8554        .unwrap();
8555        assert_eq!(writer_false.get_content(), "<div></div>");
8556    }
8557
8558    #[test]
8559    fn test_for_if_overlapping_local_state() {
8560        let mut fragments = HashMap::new();
8561        fragments.insert(
8562            "index.html".to_string(),
8563            FragmentList {
8564                fragments: vec![
8565                    WebUIFragment::raw("<div>"),
8566                    WebUIFragment::for_loop("item", "items", "template1"),
8567                    WebUIFragment::raw("</div>"),
8568                ],
8569                contains_boundary: false,
8570            },
8571        );
8572        fragments.insert(
8573            "template1".to_string(),
8574            FragmentList {
8575                fragments: vec![
8576                    WebUIFragment::raw("<div>"),
8577                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8578                    WebUIFragment::raw("</div>"),
8579                ],
8580                contains_boundary: false,
8581            },
8582        );
8583        fragments.insert(
8584            "ifBlock".to_string(),
8585            FragmentList {
8586                fragments: vec![
8587                    WebUIFragment::raw("<span>"),
8588                    WebUIFragment::signal("item.label", false),
8589                    WebUIFragment::raw("</span>"),
8590                ],
8591                contains_boundary: false,
8592            },
8593        );
8594        let protocol = WebUIProtocol::new(fragments);
8595        let state = test_json!({
8596            "flag": false,
8597            "items": [
8598                {"label": "A", "flag": true},
8599                {"label": "B", "flag": false},
8600                {"label": "C", "flag": true}
8601            ]
8602        });
8603        let mut writer = TestWriter::new();
8604        handle(
8605            &protocol,
8606            &state,
8607            &RenderOptions::new("index.html", "/"),
8608            &mut writer,
8609        )
8610        .unwrap();
8611        assert_eq!(
8612            writer.get_content(),
8613            "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
8614        );
8615    }
8616
8617    #[test]
8618    fn test_for_if_global_flag_no_effect() {
8619        let mut fragments = HashMap::new();
8620        fragments.insert(
8621            "index.html".to_string(),
8622            FragmentList {
8623                fragments: vec![
8624                    WebUIFragment::raw("<div>"),
8625                    WebUIFragment::for_loop("item", "items", "template1"),
8626                    WebUIFragment::raw("</div>"),
8627                ],
8628                contains_boundary: false,
8629            },
8630        );
8631        fragments.insert(
8632            "template1".to_string(),
8633            FragmentList {
8634                fragments: vec![
8635                    WebUIFragment::raw("<div>"),
8636                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8637                    WebUIFragment::raw("</div>"),
8638                ],
8639                contains_boundary: false,
8640            },
8641        );
8642        fragments.insert(
8643            "ifBlock".to_string(),
8644            FragmentList {
8645                fragments: vec![
8646                    WebUIFragment::raw("<span>"),
8647                    WebUIFragment::signal("item.label", false),
8648                    WebUIFragment::raw("</span>"),
8649                ],
8650                contains_boundary: false,
8651            },
8652        );
8653        let protocol = WebUIProtocol::new(fragments);
8654        let state = test_json!({
8655            "item": {"flag": true},
8656            "items": [
8657                {"label": "A", "flag": false},
8658                {"label": "B", "flag": true},
8659                {"label": "C", "flag": false}
8660            ]
8661        });
8662        let mut writer = TestWriter::new();
8663        handle(
8664            &protocol,
8665            &state,
8666            &RenderOptions::new("index.html", "/"),
8667            &mut writer,
8668        )
8669        .unwrap();
8670        assert_eq!(
8671            writer.get_content(),
8672            "<div><div></div><div><span>B</span></div><div></div></div>"
8673        );
8674    }
8675
8676    // ── Recursive template test ──────────────────────────────────────
8677
8678    #[test]
8679    fn test_recursive_template_refs() {
8680        let mut fragments = HashMap::new();
8681        fragments.insert(
8682            "index.html".to_string(),
8683            FragmentList {
8684                fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
8685                contains_boundary: false,
8686            },
8687        );
8688        fragments.insert(
8689            "static".to_string(),
8690            FragmentList {
8691                fragments: vec![
8692                    WebUIFragment::raw("<div expanded=\""),
8693                    WebUIFragment::signal("item.expanded", false),
8694                    WebUIFragment::raw("\" class=\""),
8695                    WebUIFragment::signal("testScenario", false),
8696                    WebUIFragment::raw("\"><span>"),
8697                    WebUIFragment::signal("item.name", false),
8698                    WebUIFragment::raw("</span>"),
8699                    WebUIFragment::for_loop("item", "item.children", "static"),
8700                    WebUIFragment::raw("</div>"),
8701                ],
8702                contains_boundary: false,
8703            },
8704        );
8705        let protocol = WebUIProtocol::new(fragments);
8706        let state = test_json!({
8707            "testScenario": "RecursiveTemplatesWithGlobalState",
8708            "items": [
8709                {"name": "A", "expanded": "false", "children": []},
8710                {"name": "B", "expanded": "true", "children": [
8711                    {"name": "C", "expanded": "false"},
8712                    {"name": "D", "expanded": "false"}
8713                ]},
8714                {"name": "E", "expanded": "false"}
8715            ]
8716        });
8717        let mut writer = TestWriter::new();
8718        handle(
8719            &protocol,
8720            &state,
8721            &RenderOptions::new("index.html", "/"),
8722            &mut writer,
8723        )
8724        .unwrap();
8725        assert_eq!(
8726            writer.get_content(),
8727            "<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>"
8728        );
8729    }
8730
8731    // ── Advanced state management tests ──────────────────────────────
8732
8733    #[test]
8734    fn test_component_in_for_no_local_access() {
8735        let mut fragments = HashMap::new();
8736        fragments.insert(
8737            "index.html".to_string(),
8738            FragmentList {
8739                fragments: vec![
8740                    WebUIFragment::raw("<div>"),
8741                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8742                    WebUIFragment::raw("</div>"),
8743                ],
8744                contains_boundary: false,
8745            },
8746        );
8747        fragments.insert(
8748            "templateComponent".to_string(),
8749            FragmentList {
8750                fragments: vec![
8751                    WebUIFragment::raw("<component-tag>"),
8752                    WebUIFragment::component("my-component"),
8753                    WebUIFragment::raw("</component-tag>"),
8754                ],
8755                contains_boundary: false,
8756            },
8757        );
8758        fragments.insert(
8759            "my-component".to_string(),
8760            FragmentList {
8761                fragments: vec![
8762                    WebUIFragment::raw("<span>"),
8763                    WebUIFragment::signal("name", false),
8764                    WebUIFragment::raw("</span>"),
8765                ],
8766                contains_boundary: false,
8767            },
8768        );
8769        let protocol = WebUIProtocol::new(fragments);
8770        let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
8771        let mut writer = TestWriter::new();
8772        handle(
8773            &protocol,
8774            &state,
8775            &RenderOptions::new("index.html", "/"),
8776            &mut writer,
8777        )
8778        .unwrap();
8779        assert_eq!(
8780            writer.get_content(),
8781            "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
8782        );
8783    }
8784
8785    #[test]
8786    fn test_nested_for_hierarchical_state() {
8787        let mut fragments = HashMap::new();
8788        fragments.insert(
8789            "index.html".to_string(),
8790            FragmentList {
8791                fragments: vec![
8792                    WebUIFragment::raw("<div>"),
8793                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8794                    WebUIFragment::raw("</div>"),
8795                ],
8796                contains_boundary: false,
8797            },
8798        );
8799        fragments.insert(
8800            "outerTemplate".to_string(),
8801            FragmentList {
8802                fragments: vec![
8803                    WebUIFragment::raw("<section>"),
8804                    WebUIFragment::signal("globalPrefix", false),
8805                    WebUIFragment::signal("outerItem.outerLabel", false),
8806                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8807                    WebUIFragment::raw("</section>"),
8808                ],
8809                contains_boundary: false,
8810            },
8811        );
8812        fragments.insert(
8813            "innerTemplate".to_string(),
8814            FragmentList {
8815                fragments: vec![
8816                    WebUIFragment::raw("<p>"),
8817                    WebUIFragment::signal("globalPrefix", false),
8818                    WebUIFragment::signal("outerItem.outerLabel", false),
8819                    WebUIFragment::raw(": "),
8820                    WebUIFragment::signal("innerItem.innerLabel", false),
8821                    WebUIFragment::raw("</p>"),
8822                ],
8823                contains_boundary: false,
8824            },
8825        );
8826        let protocol = WebUIProtocol::new(fragments);
8827        let state = test_json!({
8828            "globalPrefix": "Prefix: ",
8829            "outerItems": [
8830                {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8831                {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
8832            ]
8833        });
8834        let mut writer = TestWriter::new();
8835        handle(
8836            &protocol,
8837            &state,
8838            &RenderOptions::new("index.html", "/"),
8839            &mut writer,
8840        )
8841        .unwrap();
8842        assert_eq!(
8843            writer.get_content(),
8844            "<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>"
8845        );
8846    }
8847
8848    #[test]
8849    fn test_component_in_for_global_only() {
8850        let mut fragments = HashMap::new();
8851        fragments.insert(
8852            "index.html".to_string(),
8853            FragmentList {
8854                fragments: vec![
8855                    WebUIFragment::raw("<div>"),
8856                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8857                    WebUIFragment::raw("</div>"),
8858                ],
8859                contains_boundary: false,
8860            },
8861        );
8862        fragments.insert(
8863            "templateComponent".to_string(),
8864            FragmentList {
8865                fragments: vec![
8866                    WebUIFragment::raw("<component-tag>"),
8867                    WebUIFragment::component("my-component"),
8868                    WebUIFragment::raw("</component-tag>"),
8869                ],
8870                contains_boundary: false,
8871            },
8872        );
8873        fragments.insert(
8874            "my-component".to_string(),
8875            FragmentList {
8876                fragments: vec![
8877                    WebUIFragment::raw("<span>"),
8878                    WebUIFragment::signal("name", false),
8879                    WebUIFragment::raw("-"),
8880                    WebUIFragment::signal("globalSuffix", false),
8881                    WebUIFragment::raw("</span>"),
8882                ],
8883                contains_boundary: false,
8884            },
8885        );
8886        let protocol = WebUIProtocol::new(fragments);
8887        let state =
8888            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8889        let mut writer = TestWriter::new();
8890        handle(
8891            &protocol,
8892            &state,
8893            &RenderOptions::new("index.html", "/"),
8894            &mut writer,
8895        )
8896        .unwrap();
8897        assert_eq!(
8898            writer.get_content(),
8899            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8900        );
8901    }
8902
8903    #[test]
8904    fn test_component_no_item_moniker() {
8905        let mut fragments = HashMap::new();
8906        fragments.insert(
8907            "index.html".to_string(),
8908            FragmentList {
8909                fragments: vec![
8910                    WebUIFragment::raw("<div>"),
8911                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8912                    WebUIFragment::raw("</div>"),
8913                ],
8914                contains_boundary: false,
8915            },
8916        );
8917        fragments.insert(
8918            "templateComponent".to_string(),
8919            FragmentList {
8920                fragments: vec![
8921                    WebUIFragment::raw("<component-tag>"),
8922                    WebUIFragment::component("my-component"),
8923                    WebUIFragment::raw("</component-tag>"),
8924                ],
8925                contains_boundary: false,
8926            },
8927        );
8928        fragments.insert(
8929            "my-component".to_string(),
8930            FragmentList {
8931                fragments: vec![
8932                    WebUIFragment::raw("<span>"),
8933                    WebUIFragment::signal("item.name", false),
8934                    WebUIFragment::raw("-"),
8935                    WebUIFragment::signal("globalSuffix", false),
8936                    WebUIFragment::raw("</span>"),
8937                ],
8938                contains_boundary: false,
8939            },
8940        );
8941        let protocol = WebUIProtocol::new(fragments);
8942        let state =
8943            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8944        let mut writer = TestWriter::new();
8945        handle(
8946            &protocol,
8947            &state,
8948            &RenderOptions::new("index.html", "/"),
8949            &mut writer,
8950        )
8951        .unwrap();
8952        assert_eq!(
8953            writer.get_content(),
8954            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8955        );
8956    }
8957
8958    #[test]
8959    fn test_for_nonqualified_uses_global() {
8960        let mut fragments = HashMap::new();
8961        fragments.insert(
8962            "index.html".to_string(),
8963            FragmentList {
8964                fragments: vec![
8965                    WebUIFragment::raw("<div>"),
8966                    WebUIFragment::for_loop("item", "items", "template1"),
8967                    WebUIFragment::raw("</div>"),
8968                ],
8969                contains_boundary: false,
8970            },
8971        );
8972        fragments.insert(
8973            "template1".to_string(),
8974            FragmentList {
8975                fragments: vec![
8976                    WebUIFragment::raw("<span>"),
8977                    WebUIFragment::signal("name", false),
8978                    WebUIFragment::raw("</span>"),
8979                ],
8980                contains_boundary: false,
8981            },
8982        );
8983        let protocol = WebUIProtocol::new(fragments);
8984        let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
8985        let mut writer = TestWriter::new();
8986        handle(
8987            &protocol,
8988            &state,
8989            &RenderOptions::new("index.html", "/"),
8990            &mut writer,
8991        )
8992        .unwrap();
8993        assert_eq!(
8994            writer.get_content(),
8995            "<div><span>GlobalName</span><span>GlobalName</span></div>"
8996        );
8997    }
8998
8999    #[test]
9000    fn test_nested_for_if_interleaved() {
9001        let mut fragments = HashMap::new();
9002        fragments.insert(
9003            "index.html".to_string(),
9004            FragmentList {
9005                fragments: vec![
9006                    WebUIFragment::raw("<div>"),
9007                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9008                    WebUIFragment::raw("</div>"),
9009                ],
9010                contains_boundary: false,
9011            },
9012        );
9013        fragments.insert(
9014            "outerTemplate".to_string(),
9015            FragmentList {
9016                fragments: vec![
9017                    WebUIFragment::raw("<section>"),
9018                    WebUIFragment::signal("globalPrefix", false),
9019                    WebUIFragment::signal("outerItem.outerLabel", false),
9020                    WebUIFragment::if_cond(
9021                        ConditionExpr::identifier("outerItem.include"),
9022                        "ifTemplate",
9023                    ),
9024                    WebUIFragment::raw("</section>"),
9025                ],
9026                contains_boundary: false,
9027            },
9028        );
9029        fragments.insert(
9030            "ifTemplate".to_string(),
9031            FragmentList {
9032                fragments: vec![
9033                    WebUIFragment::raw("<div>"),
9034                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
9035                    WebUIFragment::raw("</div>"),
9036                ],
9037                contains_boundary: false,
9038            },
9039        );
9040        fragments.insert(
9041            "innerTemplate".to_string(),
9042            FragmentList {
9043                fragments: vec![
9044                    WebUIFragment::raw("<p>"),
9045                    WebUIFragment::signal("globalSuffix", false),
9046                    WebUIFragment::raw(": "),
9047                    WebUIFragment::signal("innerItem.innerLabel", false),
9048                    WebUIFragment::raw("</p>"),
9049                ],
9050                contains_boundary: false,
9051            },
9052        );
9053        let protocol = WebUIProtocol::new(fragments);
9054        let state = test_json!({
9055            "globalPrefix": "Prefix: ",
9056            "globalSuffix": "Suffix",
9057            "outerItems": [
9058                {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
9059                {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
9060                {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
9061            ]
9062        });
9063        let mut writer = TestWriter::new();
9064        handle(
9065            &protocol,
9066            &state,
9067            &RenderOptions::new("index.html", "/"),
9068            &mut writer,
9069        )
9070        .unwrap();
9071        assert_eq!(
9072            writer.get_content(),
9073            "<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>"
9074        );
9075    }
9076
9077    #[test]
9078    fn test_nested_for_if_outer_state() {
9079        let mut fragments = HashMap::new();
9080        fragments.insert(
9081            "index.html".to_string(),
9082            FragmentList {
9083                fragments: vec![
9084                    WebUIFragment::raw("<div>"),
9085                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9086                    WebUIFragment::raw("</div>"),
9087                ],
9088                contains_boundary: false,
9089            },
9090        );
9091        fragments.insert(
9092            "outerTemplate".to_string(),
9093            FragmentList {
9094                fragments: vec![
9095                    WebUIFragment::raw("<section>"),
9096                    WebUIFragment::signal("globalPrefix", false),
9097                    WebUIFragment::signal("outerItem.label", false),
9098                    WebUIFragment::for_loop(
9099                        "middleItem",
9100                        "outerItem.middleItems",
9101                        "middleTemplate",
9102                    ),
9103                    WebUIFragment::raw("</section>"),
9104                ],
9105                contains_boundary: false,
9106            },
9107        );
9108        fragments.insert(
9109            "middleTemplate".to_string(),
9110            FragmentList {
9111                fragments: vec![
9112                    WebUIFragment::raw("<div>"),
9113                    WebUIFragment::if_cond(
9114                        ConditionExpr::identifier("outerItem.active"),
9115                        "ifTemplate",
9116                    ),
9117                    WebUIFragment::raw("</div>"),
9118                ],
9119                contains_boundary: false,
9120            },
9121        );
9122        fragments.insert(
9123            "ifTemplate".to_string(),
9124            FragmentList {
9125                fragments: vec![
9126                    WebUIFragment::raw("<p>"),
9127                    WebUIFragment::signal("middleItem.value", false),
9128                    WebUIFragment::raw("</p>"),
9129                ],
9130                contains_boundary: false,
9131            },
9132        );
9133        let protocol = WebUIProtocol::new(fragments);
9134        let state = test_json!({
9135            "globalPrefix": "GP-",
9136            "outerItems": [
9137                {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
9138                {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
9139                {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
9140            ]
9141        });
9142        let mut writer = TestWriter::new();
9143        handle(
9144            &protocol,
9145            &state,
9146            &RenderOptions::new("index.html", "/"),
9147            &mut writer,
9148        )
9149        .unwrap();
9150        assert_eq!(
9151            writer.get_content(),
9152            "<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>"
9153        );
9154    }
9155
9156    #[test]
9157    fn test_nested_for_if_inner_state() {
9158        let mut fragments = HashMap::new();
9159        fragments.insert(
9160            "index.html".to_string(),
9161            FragmentList {
9162                fragments: vec![
9163                    WebUIFragment::raw("<div>"),
9164                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9165                    WebUIFragment::raw("</div>"),
9166                ],
9167                contains_boundary: false,
9168            },
9169        );
9170        fragments.insert(
9171            "outerTemplate".to_string(),
9172            FragmentList {
9173                fragments: vec![
9174                    WebUIFragment::raw("<section>"),
9175                    WebUIFragment::signal("outerItem.label", false),
9176                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
9177                    WebUIFragment::raw("</section>"),
9178                ],
9179                contains_boundary: false,
9180            },
9181        );
9182        fragments.insert(
9183            "innerTemplate".to_string(),
9184            FragmentList {
9185                fragments: vec![
9186                    WebUIFragment::raw("<article>"),
9187                    WebUIFragment::if_cond(
9188                        ConditionExpr::identifier("innerItem.show"),
9189                        "ifTemplate",
9190                    ),
9191                    WebUIFragment::raw("</article>"),
9192                ],
9193                contains_boundary: false,
9194            },
9195        );
9196        fragments.insert(
9197            "ifTemplate".to_string(),
9198            FragmentList {
9199                fragments: vec![
9200                    WebUIFragment::raw("<p>"),
9201                    WebUIFragment::signal("innerItem.detail", false),
9202                    WebUIFragment::raw("</p>"),
9203                ],
9204                contains_boundary: false,
9205            },
9206        );
9207        let protocol = WebUIProtocol::new(fragments);
9208        let state = test_json!({
9209            "outerItems": [
9210                {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
9211                {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
9212            ]
9213        });
9214        let mut writer = TestWriter::new();
9215        handle(
9216            &protocol,
9217            &state,
9218            &RenderOptions::new("index.html", "/"),
9219            &mut writer,
9220        )
9221        .unwrap();
9222        assert_eq!(
9223            writer.get_content(),
9224            "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
9225        );
9226    }
9227
9228    #[test]
9229    fn test_for_merge_local_global_monikers() {
9230        let mut fragments = HashMap::new();
9231        fragments.insert(
9232            "index.html".to_string(),
9233            FragmentList {
9234                fragments: vec![
9235                    WebUIFragment::raw("<div>"),
9236                    WebUIFragment::for_loop("item", "items", "template1"),
9237                    WebUIFragment::raw("</div>"),
9238                ],
9239                contains_boundary: false,
9240            },
9241        );
9242        fragments.insert(
9243            "template1".to_string(),
9244            FragmentList {
9245                fragments: vec![
9246                    WebUIFragment::raw("<span>"),
9247                    WebUIFragment::signal("item.name", false),
9248                    WebUIFragment::raw("-"),
9249                    WebUIFragment::signal("item.globalValue", false),
9250                    WebUIFragment::raw("-"),
9251                    WebUIFragment::signal("item.localOnly", false),
9252                    WebUIFragment::raw("-"),
9253                    WebUIFragment::signal("item.otherVal", false),
9254                    WebUIFragment::raw("</span>"),
9255                ],
9256                contains_boundary: false,
9257            },
9258        );
9259        let protocol = WebUIProtocol::new(fragments);
9260        let state = test_json!({
9261            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
9262            "items": [
9263                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
9264                {"name": "Local2", "localOnly": "Only2"}
9265            ]
9266        });
9267        let mut writer = TestWriter::new();
9268        handle(
9269            &protocol,
9270            &state,
9271            &RenderOptions::new("index.html", "/"),
9272            &mut writer,
9273        )
9274        .unwrap();
9275        assert_eq!(
9276            writer.get_content(),
9277            "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
9278        );
9279    }
9280
9281    #[test]
9282    fn test_component_in_for_global_moniker_shadow() {
9283        let mut fragments = HashMap::new();
9284        fragments.insert(
9285            "index.html".to_string(),
9286            FragmentList {
9287                fragments: vec![
9288                    WebUIFragment::raw("<div>"),
9289                    WebUIFragment::for_loop("item", "items", "templateComponent"),
9290                    WebUIFragment::raw("</div>"),
9291                ],
9292                contains_boundary: false,
9293            },
9294        );
9295        fragments.insert(
9296            "templateComponent".to_string(),
9297            FragmentList {
9298                fragments: vec![
9299                    WebUIFragment::raw("<component-tag>"),
9300                    WebUIFragment::component("my-component"),
9301                    WebUIFragment::raw("</component-tag>"),
9302                ],
9303                contains_boundary: false,
9304            },
9305        );
9306        fragments.insert(
9307            "my-component".to_string(),
9308            FragmentList {
9309                fragments: vec![
9310                    WebUIFragment::raw("<span>"),
9311                    WebUIFragment::signal("name", false),
9312                    WebUIFragment::raw("-"),
9313                    WebUIFragment::signal("item.globalValue", false),
9314                    WebUIFragment::raw("-"),
9315                    WebUIFragment::signal("localOnly", false),
9316                    WebUIFragment::raw("-"),
9317                    WebUIFragment::signal("item.otherVal", false),
9318                    WebUIFragment::raw("</span>"),
9319                ],
9320                contains_boundary: false,
9321            },
9322        );
9323        let protocol = WebUIProtocol::new(fragments);
9324        let state = test_json!({
9325            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
9326            "items": [
9327                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
9328                {"name": "Local2", "localOnly": "Only2"}
9329            ]
9330        });
9331        let mut writer = TestWriter::new();
9332        handle(
9333            &protocol,
9334            &state,
9335            &RenderOptions::new("index.html", "/"),
9336            &mut writer,
9337        )
9338        .unwrap();
9339        assert_eq!(
9340            writer.get_content(),
9341            "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
9342        );
9343    }
9344
9345    #[test]
9346    fn test_if_in_nested_for_local_flag() {
9347        let mut fragments = HashMap::new();
9348        fragments.insert(
9349            "index.html".to_string(),
9350            FragmentList {
9351                fragments: vec![
9352                    WebUIFragment::raw("<div>"),
9353                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
9354                    WebUIFragment::raw("</div>"),
9355                ],
9356                contains_boundary: false,
9357            },
9358        );
9359        fragments.insert(
9360            "outerTemplate".to_string(),
9361            FragmentList {
9362                fragments: vec![
9363                    WebUIFragment::raw("<section>"),
9364                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
9365                    WebUIFragment::raw("</section>"),
9366                ],
9367                contains_boundary: false,
9368            },
9369        );
9370        fragments.insert(
9371            "innerTemplate".to_string(),
9372            FragmentList {
9373                fragments: vec![WebUIFragment::if_cond(
9374                    ConditionExpr::identifier("inner_item.flag"),
9375                    "ifInner",
9376                )],
9377                contains_boundary: false,
9378            },
9379        );
9380        fragments.insert(
9381            "ifInner".to_string(),
9382            FragmentList {
9383                fragments: vec![
9384                    WebUIFragment::raw("<p>"),
9385                    WebUIFragment::signal("inner_item.value", false),
9386                    WebUIFragment::raw("</p>"),
9387                ],
9388                contains_boundary: false,
9389            },
9390        );
9391        let protocol = WebUIProtocol::new(fragments);
9392        let state = test_json!({
9393            "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
9394            "inner_item": {"flag": false}
9395        });
9396        let mut writer = TestWriter::new();
9397        handle(
9398            &protocol,
9399            &state,
9400            &RenderOptions::new("index.html", "/"),
9401            &mut writer,
9402        )
9403        .unwrap();
9404        assert_eq!(
9405            writer.get_content(),
9406            "<div><section><p>X</p></section></div>"
9407        );
9408    }
9409
9410    #[test]
9411    fn test_if_in_nested_for_global_fallback() {
9412        let mut fragments = HashMap::new();
9413        fragments.insert(
9414            "index.html".to_string(),
9415            FragmentList {
9416                fragments: vec![
9417                    WebUIFragment::raw("<div>"),
9418                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
9419                    WebUIFragment::raw("</div>"),
9420                ],
9421                contains_boundary: false,
9422            },
9423        );
9424        fragments.insert(
9425            "outerTemplate".to_string(),
9426            FragmentList {
9427                fragments: vec![
9428                    WebUIFragment::raw("<section>"),
9429                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
9430                    WebUIFragment::raw("</section>"),
9431                ],
9432                contains_boundary: false,
9433            },
9434        );
9435        fragments.insert(
9436            "innerTemplate".to_string(),
9437            FragmentList {
9438                fragments: vec![WebUIFragment::if_cond(
9439                    ConditionExpr::identifier("inner_item.flag"),
9440                    "ifInner",
9441                )],
9442                contains_boundary: false,
9443            },
9444        );
9445        fragments.insert(
9446            "ifInner".to_string(),
9447            FragmentList {
9448                fragments: vec![
9449                    WebUIFragment::raw("<p>"),
9450                    WebUIFragment::signal("inner_item.value", false),
9451                    WebUIFragment::raw("</p>"),
9452                ],
9453                contains_boundary: false,
9454            },
9455        );
9456        let protocol = WebUIProtocol::new(fragments);
9457        let state = test_json!({
9458            "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
9459            "inner_item": {"flag": true}
9460        });
9461        let mut writer = TestWriter::new();
9462        handle(
9463            &protocol,
9464            &state,
9465            &RenderOptions::new("index.html", "/"),
9466            &mut writer,
9467        )
9468        .unwrap();
9469        assert_eq!(
9470            writer.get_content(),
9471            "<div><section><p>X</p><p>Y</p></section></div>"
9472        );
9473    }
9474
9475    #[test]
9476    fn test_if_mixed_for_monikers() {
9477        let mut fragments = HashMap::new();
9478        fragments.insert(
9479            "index.html".to_string(),
9480            FragmentList {
9481                fragments: vec![
9482                    WebUIFragment::raw("<div>"),
9483                    WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
9484                    WebUIFragment::raw("</div>"),
9485                ],
9486                contains_boundary: false,
9487            },
9488        );
9489        fragments.insert(
9490            "outerTemplate".to_string(),
9491            FragmentList {
9492                fragments: vec![
9493                    WebUIFragment::raw("<section>"),
9494                    WebUIFragment::signal("outer.outerLabel", false),
9495                    WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
9496                    WebUIFragment::raw("</section>"),
9497                ],
9498                contains_boundary: false,
9499            },
9500        );
9501        fragments.insert(
9502            "innerTemplate".to_string(),
9503            FragmentList {
9504                fragments: vec![WebUIFragment::if_cond(
9505                    ConditionExpr::compound(
9506                        ConditionExpr::identifier("outer.active"),
9507                        LogicalOperator::And,
9508                        ConditionExpr::predicate(
9509                            "inner.value",
9510                            ComparisonOperator::GreaterThan,
9511                            "globalLimit",
9512                        ),
9513                    ),
9514                    "ifInner",
9515                )],
9516                contains_boundary: false,
9517            },
9518        );
9519        fragments.insert(
9520            "ifInner".to_string(),
9521            FragmentList {
9522                fragments: vec![
9523                    WebUIFragment::raw("<p>"),
9524                    WebUIFragment::signal("inner.value", false),
9525                    WebUIFragment::raw("</p>"),
9526                ],
9527                contains_boundary: false,
9528            },
9529        );
9530        let protocol = WebUIProtocol::new(fragments);
9531        let state = test_json!({
9532            "globalLimit": 10,
9533            "list": {"outerItems": [
9534                {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
9535                {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
9536                {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
9537            ]}
9538        });
9539        let mut writer = TestWriter::new();
9540        handle(
9541            &protocol,
9542            &state,
9543            &RenderOptions::new("index.html", "/"),
9544            &mut writer,
9545        )
9546        .unwrap();
9547        assert_eq!(
9548            writer.get_content(),
9549            "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
9550        );
9551    }
9552
9553    // ── Route-aware rendering tests ─────────────────────────────────────
9554
9555    fn make_route_protocol() -> WebUIProtocol {
9556        use webui_protocol::WebUiFragmentRoute;
9557
9558        let mut fragments = HashMap::new();
9559
9560        // Entry page with two routes
9561        fragments.insert(
9562            "index.html".to_string(),
9563            FragmentList {
9564                fragments: vec![
9565                    WebUIFragment::raw("<h1>Shell</h1>"),
9566                    WebUIFragment::route_from(WebUiFragmentRoute {
9567                        path: "/".into(),
9568                        fragment_id: "dash-page".into(),
9569                        exact: true,
9570                        keep_alive: false,
9571                        ..Default::default()
9572                    }),
9573                    WebUIFragment::route_from(WebUiFragmentRoute {
9574                        path: "/contacts/:id".into(),
9575                        fragment_id: "detail-page".into(),
9576                        exact: true,
9577                        keep_alive: true,
9578                        ..Default::default()
9579                    }),
9580                ],
9581
9582                contains_boundary: false,
9583            },
9584        );
9585
9586        // Dashboard page component
9587        fragments.insert(
9588            "dash-page".to_string(),
9589            FragmentList {
9590                fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
9591                contains_boundary: false,
9592            },
9593        );
9594
9595        // Detail page component
9596        fragments.insert(
9597            "detail-page".to_string(),
9598            FragmentList {
9599                fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
9600                contains_boundary: false,
9601            },
9602        );
9603
9604        WebUIProtocol::new(fragments)
9605    }
9606
9607    fn make_nested_route_protocol() -> WebUIProtocol {
9608        use webui_protocol::WebUiFragmentRoute;
9609
9610        let mut fragments = HashMap::new();
9611
9612        fragments.insert(
9613            "index.html".to_string(),
9614            FragmentList {
9615                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
9616                    path: "/".into(),
9617                    fragment_id: "app-shell".into(),
9618                    exact: false,
9619                    children: vec![WebUiFragmentRoute {
9620                        path: "sections/:id".into(),
9621                        fragment_id: "section-comp".into(),
9622                        exact: false,
9623                        children: vec![WebUiFragmentRoute {
9624                            path: "topics/:topicId".into(),
9625                            fragment_id: "topic-comp".into(),
9626                            exact: true,
9627                            children: vec![],
9628                            keep_alive: false,
9629                            ..Default::default()
9630                        }],
9631                        keep_alive: false,
9632                        ..Default::default()
9633                    }],
9634                    keep_alive: false,
9635                    ..Default::default()
9636                })],
9637
9638                contains_boundary: false,
9639            },
9640        );
9641
9642        fragments.insert(
9643            "app-shell".to_string(),
9644            FragmentList {
9645                fragments: vec![
9646                    WebUIFragment::raw("<h1>Shell</h1>"),
9647                    WebUIFragment::outlet(),
9648                ],
9649                contains_boundary: false,
9650            },
9651        );
9652
9653        fragments.insert(
9654            "section-comp".to_string(),
9655            FragmentList {
9656                fragments: vec![
9657                    WebUIFragment::raw("<h2>Section</h2>"),
9658                    WebUIFragment::outlet(),
9659                ],
9660                contains_boundary: false,
9661            },
9662        );
9663
9664        fragments.insert(
9665            "topic-comp".to_string(),
9666            FragmentList {
9667                fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
9668                contains_boundary: false,
9669            },
9670        );
9671
9672        WebUIProtocol::new(fragments)
9673    }
9674
9675    #[test]
9676    fn test_route_renders_shell_always() {
9677        let protocol = make_route_protocol();
9678        let state = test_json!({});
9679        let mut writer = TestWriter::new();
9680        handle(
9681            &protocol,
9682            &state,
9683            &RenderOptions::new("index.html", "/"),
9684            &mut writer,
9685        )
9686        .unwrap();
9687        let html = writer.get_content();
9688
9689        // Shell content always renders regardless of route matching
9690        assert!(html.contains("<h1>Shell</h1>"), "shell should render");
9691        // Dashboard matches "/" so it should be active
9692        assert!(html.contains(" active>"), "matched route should be active");
9693        // Detail should be hidden and empty
9694        assert!(
9695            html.contains("style=\"display:none\""),
9696            "non-matched routes should be hidden"
9697        );
9698    }
9699
9700    #[test]
9701    fn test_route_matched_renders_visible() {
9702        let protocol = make_route_protocol();
9703        let state = test_json!({});
9704        let mut writer = TestWriter::new();
9705        handle(
9706            &protocol,
9707            &state,
9708            &RenderOptions::new("index.html", "/"),
9709            &mut writer,
9710        )
9711        .unwrap();
9712        let html = writer.get_content();
9713
9714        // Dashboard route should be visible (active, no display:none)
9715        assert!(
9716            html.contains("<webui-route path=\"/\""),
9717            "dashboard route should exist"
9718        );
9719        assert!(
9720            html.contains("active>") && html.contains("<dash-page"),
9721            "matched route should be active with component tag: {html}"
9722        );
9723        assert!(
9724            html.contains("<p>Dashboard</p>"),
9725            "matched route should have content"
9726        );
9727    }
9728
9729    #[test]
9730    fn test_route_non_matched_renders_hidden_empty() {
9731        let protocol = make_route_protocol();
9732        let state = test_json!({});
9733        let mut writer = TestWriter::new();
9734        handle(
9735            &protocol,
9736            &state,
9737            &RenderOptions::new("index.html", "/"),
9738            &mut writer,
9739        )
9740        .unwrap();
9741        let html = writer.get_content();
9742
9743        // Detail route should be hidden and empty (no content rendered)
9744        assert!(
9745            html.contains("<webui-route path=\"/contacts/:id\""),
9746            "detail route element should exist"
9747        );
9748        // The non-matched route should have display:none and no inner content
9749        let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
9750        let after_detail = &html[detail_start..];
9751        assert!(
9752            after_detail.contains("style=\"display:none\">")
9753                && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
9754            "non-matched route should be hidden: {after_detail}"
9755        );
9756        // Should NOT contain the component's rendered content
9757        let detail_end = after_detail.find("</webui-route>").expect("closing tag");
9758        let detail_body = &after_detail[..detail_end];
9759        assert!(
9760            !detail_body.contains("<detail-page"),
9761            "non-matched route should not render component content: {detail_body}"
9762        );
9763    }
9764
9765    #[test]
9766    fn test_route_parameterized_match() {
9767        let protocol = make_route_protocol();
9768        let state = test_json!({});
9769        let mut writer = TestWriter::new();
9770        handle(
9771            &protocol,
9772            &state,
9773            &RenderOptions::new("index.html", "/contacts/42"),
9774            &mut writer,
9775        )
9776        .unwrap();
9777        let html = writer.get_content();
9778
9779        // Detail route matches /contacts/42
9780        assert!(
9781            html.contains("active>") && html.contains("<detail-page"),
9782            "detail route should be active: {html}"
9783        );
9784        assert!(html.contains("<p>Detail</p>"), "detail should have content");
9785        // Dashboard should be hidden + empty
9786        let dash_start = html
9787            .find("component=\"dash-page\"")
9788            .expect("dashboard route");
9789        let after_dash = &html[dash_start..];
9790        assert!(
9791            after_dash.contains("style=\"display:none\">"),
9792            "dashboard should be hidden when detail matches: {after_dash}"
9793        );
9794        let dash_end = after_dash.find("</webui-route>").expect("closing tag");
9795        let dash_body = &after_dash[..dash_end];
9796        assert!(
9797            !dash_body.contains("<dash-page"),
9798            "dashboard should not render component content: {dash_body}"
9799        );
9800    }
9801
9802    #[test]
9803    fn test_route_no_match_all_hidden_empty() {
9804        let protocol = make_route_protocol();
9805        let state = test_json!({});
9806        let mut writer = TestWriter::new();
9807        handle(
9808            &protocol,
9809            &state,
9810            &RenderOptions::new("index.html", "/nonexistent"),
9811            &mut writer,
9812        )
9813        .unwrap();
9814        let html = writer.get_content();
9815
9816        // Shell content should still render
9817        assert!(html.contains("<h1>Shell</h1>"));
9818        // All routes should be hidden + empty (nothing matched)
9819        assert!(
9820            !html.contains("<p>Dashboard</p>"),
9821            "no route content when nothing matches"
9822        );
9823        assert!(
9824            !html.contains("<p>Detail</p>"),
9825            "no route content when nothing matches"
9826        );
9827    }
9828
9829    #[test]
9830    fn test_route_component_attr_emitted() {
9831        let protocol = make_route_protocol();
9832        let state = test_json!({});
9833        let mut writer = TestWriter::new();
9834        handle(
9835            &protocol,
9836            &state,
9837            &RenderOptions::new("index.html", "/"),
9838            &mut writer,
9839        )
9840        .unwrap();
9841        let html = writer.get_content();
9842        // component attribute should be emitted on webui-route
9843        assert!(
9844            html.contains("component=\"dash-page\""),
9845            "component attr should be on webui-route: {html}"
9846        );
9847        assert!(
9848            html.contains("component=\"detail-page\""),
9849            "component attr should be on webui-route: {html}"
9850        );
9851        assert!(
9852            html.contains(r#"component="detail-page" exact keep-alive style="display:none">"#),
9853            "keep-alive should be emitted on an unmatched destination placeholder: {html}"
9854        );
9855    }
9856
9857    #[test]
9858    fn test_no_plugin_no_state_attributes() {
9859        let protocol = make_route_protocol();
9860        let state = test_json!({
9861            "title": "Fish & Chips",
9862            "cartOpen": true,
9863            "items": [{"name": "A&B"}]
9864        });
9865        let mut writer = TestWriter::new();
9866
9867        handle(
9868            &protocol,
9869            &state,
9870            &RenderOptions::new("index.html", "/"),
9871            &mut writer,
9872        )
9873        .unwrap();
9874
9875        let html = writer.get_content();
9876        // Without a plugin, no state attributes at all
9877        assert!(
9878            !html.contains("data-state"),
9879            "no data-state without plugin: {html}"
9880        );
9881        assert!(
9882            !html.contains(r#"title="Fish"#),
9883            "no scalar attrs without plugin: {html}"
9884        );
9885    }
9886
9887    #[test]
9888    fn test_nested_routes_render_webui_route_as_light_dom() {
9889        let protocol = make_nested_route_protocol();
9890        let state = test_json!({"title": "Test"});
9891        let handler = WebUIHandler::new();
9892        let mut writer = TestWriter::new();
9893
9894        handler
9895            .handle(
9896                &protocol,
9897                &state,
9898                &RenderOptions::new("index.html", "/sections/frontend"),
9899                &mut writer,
9900            )
9901            .expect("render failed");
9902
9903        let html = writer.get_content();
9904
9905        assert!(
9906            html.contains("component=\"app-shell\"") && html.contains("active>"),
9907            "root route should be active: {html}"
9908        );
9909        // webui-route should NOT have shadow DOM — it's a light DOM structural element
9910        assert!(
9911            !html.contains("<template shadowrootmode"),
9912            "webui-route should be light DOM (no shadow template): {html}"
9913        );
9914    }
9915
9916    #[test]
9917    fn test_nested_routes_render_outlet_as_light_dom() {
9918        let protocol = make_nested_route_protocol();
9919        let state = test_json!({"title": "Test"});
9920        let handler = WebUIHandler::new();
9921        let mut writer = TestWriter::new();
9922
9923        handler
9924            .handle(
9925                &protocol,
9926                &state,
9927                &RenderOptions::new("index.html", "/sections/frontend"),
9928                &mut writer,
9929            )
9930            .expect("render failed");
9931
9932        let html = writer.get_content();
9933
9934        // No <webui-outlet> wrapper — routes render directly at outlet position
9935        assert!(
9936            !html.contains("<webui-outlet>"),
9937            "should not contain webui-outlet wrapper: {html}"
9938        );
9939        // Route elements should be in the output directly
9940        assert!(
9941            html.contains("<webui-route"),
9942            "should contain webui-route elements: {html}"
9943        );
9944    }
9945
9946    #[test]
9947    fn test_nested_routes_match_child_at_outlet() {
9948        let protocol = make_nested_route_protocol();
9949        let state = test_json!({"title": "Test"});
9950        let handler = WebUIHandler::new();
9951        let mut writer = TestWriter::new();
9952
9953        handler
9954            .handle(
9955                &protocol,
9956                &state,
9957                &RenderOptions::new("index.html", "/sections/frontend"),
9958                &mut writer,
9959            )
9960            .expect("render failed");
9961
9962        let html = writer.get_content();
9963
9964        assert!(
9965            html.contains("component=\"section-comp\"") && html.contains("active>"),
9966            "section route should be active: {html}"
9967        );
9968        assert!(
9969            html.contains("<h2>Section</h2>"),
9970            "section content should be present: {html}"
9971        );
9972    }
9973
9974    #[test]
9975    fn test_nested_routes_three_levels_deep() {
9976        let protocol = make_nested_route_protocol();
9977        let state = test_json!({"title": "Test"});
9978        let handler = WebUIHandler::new();
9979        let mut writer = TestWriter::new();
9980
9981        handler
9982            .handle(
9983                &protocol,
9984                &state,
9985                &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
9986                &mut writer,
9987            )
9988            .expect("render failed");
9989
9990        let html = writer.get_content();
9991
9992        assert!(
9993            html.contains("component=\"app-shell\"") && html.contains("active>"),
9994            "root active: {html}"
9995        );
9996        assert!(
9997            html.contains("component=\"section-comp\"") && html.contains("active>"),
9998            "section active: {html}"
9999        );
10000        assert!(
10001            html.contains("component=\"topic-comp\"")
10002                && html.contains("exact")
10003                && html.contains("active>"),
10004            "topic active: {html}"
10005        );
10006        assert!(
10007            html.contains("<p>Topic content</p>"),
10008            "leaf content present: {html}"
10009        );
10010    }
10011
10012    #[test]
10013    fn test_nested_routes_nonmatched_siblings_hidden() {
10014        let protocol = make_nested_route_protocol();
10015        let state = test_json!({"title": "Test"});
10016        let handler = WebUIHandler::new();
10017        let mut writer = TestWriter::new();
10018
10019        handler
10020            .handle(
10021                &protocol,
10022                &state,
10023                &RenderOptions::new("index.html", "/sections/frontend"),
10024                &mut writer,
10025            )
10026            .expect("render failed");
10027
10028        let html = writer.get_content();
10029
10030        assert!(
10031            html.contains(r#"component="topic-comp" exact style="display:none">"#),
10032            "topic should be hidden: {html}"
10033        );
10034    }
10035
10036    #[test]
10037    fn test_nested_routes_preserve_sibling_declaration_order() {
10038        let mut protocol = make_nested_route_protocol();
10039        let Some(Fragment::Route(root)) = protocol
10040            .fragments
10041            .get_mut("index.html")
10042            .and_then(|entry| entry.fragments.first_mut())
10043            .and_then(|fragment| fragment.fragment.as_mut())
10044        else {
10045            panic!("nested route fixture must have a root route");
10046        };
10047        root.children.insert(
10048            0,
10049            webui_protocol::WebUiFragmentRoute {
10050                path: String::new(),
10051                fragment_id: "topic-comp".into(),
10052                exact: true,
10053                ..Default::default()
10054            },
10055        );
10056        root.children.push(webui_protocol::WebUiFragmentRoute {
10057            path: "*rest".into(),
10058            fragment_id: "topic-comp".into(),
10059            exact: true,
10060            ..Default::default()
10061        });
10062
10063        for path in ["/", "/sections/frontend", "/missing"] {
10064            let mut writer = TestWriter::new();
10065            WebUIHandler::new()
10066                .handle(
10067                    &protocol,
10068                    &test_json!({}),
10069                    &RenderOptions::new("index.html", path),
10070                    &mut writer,
10071                )
10072                .expect("render failed");
10073            let html = writer.get_content();
10074            let first = html.find(r#"<webui-route path="""#).expect("empty route");
10075            let middle = html
10076                .find(r#"<webui-route path="sections/:id""#)
10077                .expect("section route");
10078            let last = html
10079                .find(r#"<webui-route path="*rest""#)
10080                .expect("catch-all route");
10081            assert!(first < middle && middle < last, "{path}: {html}");
10082        }
10083    }
10084
10085    #[test]
10086    fn test_nested_routes_root_only() {
10087        let protocol = make_nested_route_protocol();
10088        let state = test_json!({"title": "Test"});
10089        let handler = WebUIHandler::new();
10090        let mut writer = TestWriter::new();
10091
10092        handler
10093            .handle(
10094                &protocol,
10095                &state,
10096                &RenderOptions::new("index.html", "/"),
10097                &mut writer,
10098            )
10099            .expect("render failed");
10100
10101        let html = writer.get_content();
10102
10103        assert!(
10104            html.contains("component=\"app-shell\"") && html.contains("active>"),
10105            "root active at /: {html}"
10106        );
10107        assert!(
10108            html.contains("<h1>Shell</h1>"),
10109            "shell renders at /: {html}"
10110        );
10111        assert!(
10112            html.contains(r#"component="section-comp" style="display:none">"#),
10113            "section hidden at /: {html}"
10114        );
10115    }
10116
10117    // ── CSS Module dedup tests ───────────────────────────────────────
10118
10119    #[test]
10120    fn test_css_module_emitted_once_inline_in_component() {
10121        // CSS module definition emitted once in the component's light DOM
10122        // on first render, not in <head> and not on second instance.
10123        let template = r#"<p><slot></slot></p>"#;
10124
10125        let mut fragments = HashMap::new();
10126        fragments.insert(
10127            "index.html".to_string(),
10128            FragmentList {
10129                fragments: vec![
10130                    WebUIFragment::raw("<html><head>".to_string()),
10131                    structural_fragment("head_end"),
10132                    WebUIFragment::raw("</head><body><div>".to_string()),
10133                    WebUIFragment::component("my-card"),
10134                    WebUIFragment::raw("A".to_string()),
10135                    WebUIFragment::component("my-card"),
10136                    WebUIFragment::raw("B</div>".to_string()),
10137                    structural_fragment("body_end"),
10138                    WebUIFragment::raw("</body></html>".to_string()),
10139                ],
10140                contains_boundary: false,
10141            },
10142        );
10143        fragments.insert(
10144            "my-card".to_string(),
10145            FragmentList {
10146                fragments: vec![WebUIFragment::raw(template.to_string())],
10147                contains_boundary: false,
10148            },
10149        );
10150
10151        let mut protocol = WebUIProtocol::new(fragments);
10152        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
10153        protocol
10154            .components
10155            .entry("my-card".to_string())
10156            .or_default()
10157            .css = "p{color:red}".to_string();
10158        protocol.populate_style_closures(&["index.html"]);
10159        let state = test_json!({});
10160        let mut writer = TestWriter::new();
10161
10162        handle(
10163            &protocol,
10164            &state,
10165            &RenderOptions::new("index.html", "/"),
10166            &mut writer,
10167        )
10168        .unwrap();
10169
10170        let html = writer.get_content();
10171
10172        // CSS module importmap should appear exactly once
10173        let count = html.matches(r#"<script type="importmap""#).count();
10174        assert_eq!(
10175            count, 1,
10176            "CSS module importmap should be emitted once, got {count} in: {html}"
10177        );
10178        assert!(
10179            html.contains(r#""my-card":"data:text/css,"#),
10180            "Importmap must register my-card under a data: URI: {html}"
10181        );
10182
10183        // Template content should appear twice (once per component instance)
10184        let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
10185        assert_eq!(
10186            tmpl_count, 2,
10187            "Template should render twice, got {tmpl_count} in: {html}"
10188        );
10189
10190        // CSS module should be in <body> (inline), not in <head>
10191        let css_pos = html
10192            .find(r#"<script type="importmap""#)
10193            .expect("CSS module importmap missing");
10194        let body_pos = html.find("<body>").expect("<body> missing");
10195        assert!(
10196            css_pos > body_pos,
10197            "CSS module should be inline in component, not in <head>: {html}"
10198        );
10199    }
10200
10201    #[test]
10202    fn test_component_without_css_renders_normally() {
10203        // Components without CSS module prefix pass through unchanged
10204        let mut fragments = HashMap::new();
10205        fragments.insert(
10206            "index.html".to_string(),
10207            FragmentList {
10208                fragments: vec![WebUIFragment::component("my-card")],
10209                contains_boundary: false,
10210            },
10211        );
10212        fragments.insert(
10213            "my-card".to_string(),
10214            FragmentList {
10215                fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
10216                contains_boundary: false,
10217            },
10218        );
10219
10220        let protocol = WebUIProtocol::new(fragments);
10221        let state = test_json!({});
10222        let mut writer = TestWriter::new();
10223
10224        handle(
10225            &protocol,
10226            &state,
10227            &RenderOptions::new("index.html", "/"),
10228            &mut writer,
10229        )
10230        .unwrap();
10231
10232        let html = writer.get_content();
10233        assert!(
10234            html.contains("<p>hello</p>"),
10235            "Non-module component should render normally: {html}"
10236        );
10237    }
10238
10239    #[test]
10240    fn test_non_module_strategy_no_css_in_head() {
10241        // When component_css is empty (Link/Style strategies), no
10242        // CSS module importmap tags should appear in <head>.
10243        let template = r#"<p>hello</p>"#;
10244
10245        let mut fragments = HashMap::new();
10246        fragments.insert(
10247            "index.html".to_string(),
10248            FragmentList {
10249                fragments: vec![
10250                    WebUIFragment::raw("<html><head>".to_string()),
10251                    structural_fragment("head_end"),
10252                    WebUIFragment::raw("</head><body>".to_string()),
10253                    WebUIFragment::component("my-card"),
10254                    WebUIFragment::raw("</body></html>".to_string()),
10255                ],
10256                contains_boundary: false,
10257            },
10258        );
10259        fragments.insert(
10260            "my-card".to_string(),
10261            FragmentList {
10262                fragments: vec![WebUIFragment::raw(template.to_string())],
10263                contains_boundary: false,
10264            },
10265        );
10266
10267        // No component css populated — simulates Link/Style strategy
10268        let protocol = WebUIProtocol::new(fragments);
10269        let state = test_json!({});
10270        let mut writer = TestWriter::new();
10271
10272        handle(
10273            &protocol,
10274            &state,
10275            &RenderOptions::new("index.html", "/"),
10276            &mut writer,
10277        )
10278        .unwrap();
10279
10280        let html = writer.get_content();
10281
10282        assert!(
10283            !html.contains(r#"<style type="module""#),
10284            "Non-module strategy should not emit CSS module tags in <head>: {html}"
10285        );
10286        assert!(
10287            !html.contains(r#"<script type="importmap""#),
10288            "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
10289        );
10290        assert!(
10291            html.contains("<p>hello</p>"),
10292            "Component should still render: {html}"
10293        );
10294    }
10295
10296    #[test]
10297    fn test_style_strategy_embeds_inline_style_in_shadow_template() {
10298        let mut fragments = HashMap::new();
10299        fragments.insert(
10300            "index.html".to_string(),
10301            FragmentList {
10302                fragments: vec![
10303                    WebUIFragment::raw("<html><head>".to_string()),
10304                    structural_fragment("head_end"),
10305                    WebUIFragment::raw("</head><body><my-card>".to_string()),
10306                    WebUIFragment::component("my-card"),
10307                    WebUIFragment::raw("</my-card></body></html>".to_string()),
10308                ],
10309                contains_boundary: false,
10310            },
10311        );
10312        fragments.insert(
10313            "my-card".to_string(),
10314            FragmentList {
10315                fragments: vec![WebUIFragment::raw(
10316                    "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
10317                        .to_string(),
10318                )],
10319                        contains_boundary: false,
10320},
10321        );
10322
10323        let protocol = WebUIProtocol::new(fragments);
10324        let state = test_json!({});
10325        let mut writer = TestWriter::new();
10326
10327        handle(
10328            &protocol,
10329            &state,
10330            &RenderOptions::new("index.html", "/"),
10331            &mut writer,
10332        )
10333        .unwrap();
10334
10335        let html = writer.get_content();
10336
10337        assert!(
10338            html.contains("<style>.card{color:red}</style>"),
10339            "Style strategy should embed inline CSS in shadow template: {html}"
10340        );
10341        assert!(
10342            !html.contains(r#"<style type="module""#),
10343            "Style strategy should not emit legacy module CSS in <head>: {html}"
10344        );
10345        assert!(
10346            !html.contains(r#"<script type="importmap""#),
10347            "Style strategy should not emit CSS module importmaps in <head>: {html}"
10348        );
10349    }
10350
10351    #[test]
10352    fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
10353        // Light DOM + Link strategy: handler emits <link rel="stylesheet">
10354        // in <head>. No preload tag — the stylesheet itself fetches.
10355        let mut fragments = HashMap::new();
10356        fragments.insert(
10357            "index.html".to_string(),
10358            FragmentList {
10359                fragments: vec![
10360                    WebUIFragment::raw("<html><head>".to_string()),
10361                    structural_fragment("head_end"),
10362                    WebUIFragment::raw("</head><body><my-card>".to_string()),
10363                    WebUIFragment::component("my-card"),
10364                    WebUIFragment::raw("</my-card>".to_string()),
10365                    structural_fragment("body_end"),
10366                    WebUIFragment::raw("</body></html>".to_string()),
10367                ],
10368                contains_boundary: false,
10369            },
10370        );
10371        fragments.insert(
10372            "my-card".to_string(),
10373            FragmentList {
10374                fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
10375                contains_boundary: false,
10376            },
10377        );
10378
10379        let mut protocol = WebUIProtocol::new(fragments);
10380        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10381
10382        let comp = protocol
10383            .components
10384            .entry("my-card".to_string())
10385            .or_default();
10386        comp.css_href = "my-card.css".to_string();
10387        comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
10388        protocol.populate_style_closures(&["index.html"]);
10389
10390        let state = test_json!({});
10391        let mut writer = TestWriter::new();
10392
10393        handle(
10394            &protocol,
10395            &state,
10396            &RenderOptions::new("index.html", "/"),
10397            &mut writer,
10398        )
10399        .unwrap();
10400
10401        let html = writer.get_content();
10402
10403        let head_end = html.find("</head>").expect("</head> missing");
10404        let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css""#);
10405        assert!(
10406            link_pos.is_some_and(|p| p < head_end),
10407            "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
10408        );
10409        assert!(
10410            !html.contains(r#"<link rel="preload""#),
10411            "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
10412        );
10413        assert!(
10414            !html.contains(r#"<style type="module""#),
10415            "Link strategy should not emit legacy module CSS: {html}"
10416        );
10417        assert!(
10418            !html.contains(r#"<script type="importmap""#),
10419            "Link strategy should not emit CSS module importmaps: {html}"
10420        );
10421    }
10422
10423    #[test]
10424    fn link_strategy_emits_stylesheets_in_deterministic_document_order() {
10425        // Regression: `css_hrefs`/`style_specs` must follow the traversal-ordered
10426        // `reachable` list (a `Vec<String>`), never a `HashSet<String>` — whose
10427        // iteration order depends on the process's randomized hash seed and can
10428        // silently reorder `<link>` tags between renders/process restarts, which
10429        // in turn can flip cascade-order-sensitive CSS (e.g. same-specificity
10430        // `order`/`position` rules) and move on-page elements around.
10431        let mut fragments = HashMap::new();
10432        fragments.insert(
10433            "index.html".to_string(),
10434            FragmentList {
10435                fragments: vec![
10436                    WebUIFragment::raw("<html><head>".to_string()),
10437                    structural_fragment("head_end"),
10438                    WebUIFragment::raw(
10439                        "</head><body><comp-a></comp-a><comp-b></comp-b><comp-c></comp-c>"
10440                            .to_string(),
10441                    ),
10442                    WebUIFragment::component("comp-a"),
10443                    WebUIFragment::component("comp-b"),
10444                    WebUIFragment::component("comp-c"),
10445                    structural_fragment("body_end"),
10446                    WebUIFragment::raw("</body></html>".to_string()),
10447                ],
10448                contains_boundary: false,
10449            },
10450        );
10451        for tag in ["comp-a", "comp-b", "comp-c"] {
10452            fragments.insert(
10453                tag.to_string(),
10454                FragmentList {
10455                    fragments: vec![WebUIFragment::raw(format!("<div>{tag}</div>"))],
10456                    contains_boundary: false,
10457                },
10458            );
10459        }
10460
10461        let mut protocol = WebUIProtocol::new(fragments);
10462        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10463        for tag in ["comp-a", "comp-b", "comp-c"] {
10464            let comp = protocol.components.entry(tag.to_string()).or_default();
10465            comp.css_href = format!("{tag}.css");
10466            comp.template_json = format!(r#"{{"h":"<div>{tag}</div>"}}"#);
10467        }
10468        protocol.populate_style_closures(&["index.html"]);
10469
10470        let state = test_json!({});
10471        let mut writer = TestWriter::new();
10472        handle(
10473            &protocol,
10474            &state,
10475            &RenderOptions::new("index.html", "/"),
10476            &mut writer,
10477        )
10478        .unwrap();
10479        let html = writer.get_content();
10480
10481        let pos_a = html
10482            .find(r#"href="comp-a.css""#)
10483            .expect("comp-a.css link missing");
10484        let pos_b = html
10485            .find(r#"href="comp-b.css""#)
10486            .expect("comp-b.css link missing");
10487        let pos_c = html
10488            .find(r#"href="comp-c.css""#)
10489            .expect("comp-c.css link missing");
10490        assert!(
10491            pos_a < pos_b && pos_b < pos_c,
10492            "stylesheet <link> tags must follow document/traversal order \
10493             (comp-a, comp-b, comp-c), got positions {pos_a}, {pos_b}, {pos_c}: {html}"
10494        );
10495    }
10496
10497    #[test]
10498    fn link_strategy_preloads_static_shadow_css_in_head() {
10499        let mut fragments = HashMap::new();
10500        fragments.insert(
10501            "index.html".to_string(),
10502            FragmentList {
10503                fragments: vec![
10504                    WebUIFragment::raw("<html><head>".to_string()),
10505                    structural_fragment("head_end"),
10506                    WebUIFragment::raw("</head><body><my-card>".to_string()),
10507                    WebUIFragment::component("my-card"),
10508                    WebUIFragment::raw("</my-card></body></html>".to_string()),
10509                ],
10510                contains_boundary: false,
10511            },
10512        );
10513        fragments.insert(
10514            "o-loading-state".to_string(),
10515            FragmentList {
10516                fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
10517                contains_boundary: false,
10518            },
10519        );
10520        fragments.insert(
10521            "my-card".to_string(),
10522            FragmentList {
10523                fragments: vec![
10524                    WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10525                    structural_fragment("shadow_styles:my-card"),
10526                    WebUIFragment::raw("<div>card</div></template>".to_string()),
10527                ],
10528                contains_boundary: false,
10529            },
10530        );
10531
10532        let mut protocol = WebUIProtocol::new(fragments);
10533        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10534        let component = protocol
10535            .components
10536            .entry("my-card".to_string())
10537            .or_default();
10538        component.css_href = "my-card.css".to_string();
10539        component.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
10540        component.uses_shadow_dom = true;
10541        protocol.populate_style_closures(&["index.html"]);
10542
10543        let mut writer = TestWriter::new();
10544        handle(
10545            &protocol,
10546            &test_json!({}),
10547            &RenderOptions::new("index.html", "/"),
10548            &mut writer,
10549        )
10550        .unwrap();
10551
10552        let html = writer.get_content();
10553        let head_end = html.find("</head>").expect("</head> missing");
10554        let preload = html
10555            .find(r#"<link rel="preload" as="style" href="my-card.css">"#)
10556            .expect("Shadow preload missing");
10557        let stylesheet = html
10558            .find(r#"<link rel="stylesheet" href="my-card.css""#)
10559            .expect("Shadow stylesheet missing");
10560        assert!(preload < head_end);
10561        assert!(stylesheet > head_end);
10562        assert_eq!(html.matches(r#"rel="preload" as="style""#).count(), 1);
10563    }
10564
10565    #[test]
10566    fn tree_local_styles_deduplicate_per_root_and_resume_caller_frame() {
10567        let fragments = HashMap::from([
10568            (
10569                "index.html".to_string(),
10570                FragmentList {
10571                    fragments: vec![
10572                        WebUIFragment::raw("<html><head>".to_string()),
10573                        structural_fragment("head_end"),
10574                        WebUIFragment::raw("</head><body><outer-box>".to_string()),
10575                        WebUIFragment::component("outer-box"),
10576                        WebUIFragment::raw("</outer-box></body></html>".to_string()),
10577                    ],
10578                    contains_boundary: false,
10579                },
10580            ),
10581            (
10582                "outer-box".to_string(),
10583                FragmentList {
10584                    fragments: vec![
10585                        WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10586                        structural_fragment("shadow_styles:outer-box"),
10587                        WebUIFragment::raw("<light-card>".to_string()),
10588                        WebUIFragment::component("light-card"),
10589                        WebUIFragment::raw("</light-card><light-card>".to_string()),
10590                        WebUIFragment::component("light-card"),
10591                        WebUIFragment::raw("</light-card><inner-box>".to_string()),
10592                        WebUIFragment::component("inner-box"),
10593                        WebUIFragment::raw("</inner-box><light-card>".to_string()),
10594                        WebUIFragment::component("light-card"),
10595                        WebUIFragment::raw("</light-card></template>".to_string()),
10596                    ],
10597                    contains_boundary: false,
10598                },
10599            ),
10600            (
10601                "inner-box".to_string(),
10602                FragmentList {
10603                    fragments: vec![
10604                        WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10605                        structural_fragment("shadow_styles:inner-box"),
10606                        WebUIFragment::raw("<light-card>".to_string()),
10607                        WebUIFragment::component("light-card"),
10608                        WebUIFragment::raw("</light-card></template>".to_string()),
10609                    ],
10610                    contains_boundary: false,
10611                },
10612            ),
10613            (
10614                "light-card".to_string(),
10615                FragmentList {
10616                    fragments: vec![WebUIFragment::raw("<p>card</p>".to_string())],
10617                    contains_boundary: false,
10618                },
10619            ),
10620        ]);
10621        let mut protocol = WebUIProtocol::new(fragments);
10622        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10623        for (tag, css, uses_shadow_dom) in [
10624            ("outer-box", ".outer{}", true),
10625            ("inner-box", ".inner{}", true),
10626            ("light-card", ".card{}", false),
10627        ] {
10628            let component = protocol.components.entry(tag.to_string()).or_default();
10629            component.css = css.to_string();
10630            component.uses_shadow_dom = uses_shadow_dom;
10631        }
10632        protocol.populate_style_closures(&["index.html"]);
10633
10634        let mut writer = TestWriter::new();
10635        handle(
10636            &protocol,
10637            &test_json!({}),
10638            &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10639            &mut writer,
10640        )
10641        .unwrap();
10642        let html = writer.get_content();
10643
10644        assert_eq!(html.matches("data-webui-resource=\"outer-box\"").count(), 1);
10645        assert_eq!(html.matches("data-webui-resource=\"inner-box\"").count(), 1);
10646        assert_eq!(
10647            html.matches("data-webui-resource=\"light-card\"").count(),
10648            2,
10649            "the Light resource installs once in each Shadow tree: {html}"
10650        );
10651        assert_eq!(
10652            html.matches("data-webui-resource=").count(),
10653            4,
10654            "outer-box + light-card and inner-box + light-card are the four tree-local installs"
10655        );
10656        assert_eq!(
10657            html.matches("nonce=\"css-nonce\"").count(),
10658            4,
10659            "each inline style resource carries the nonce"
10660        );
10661        assert!(html.contains(r#"<meta name="webui-nonce" content="css-nonce">"#));
10662        let inner_end = html.find("</inner-box>").expect("inner host close");
10663        let caller_continuation = &html[inner_end..];
10664        let trailing_card = caller_continuation
10665            .find("<p>card</p>")
10666            .expect("caller continues after nested Shadow root");
10667        assert!(trailing_card > 0);
10668        assert!(
10669            !caller_continuation.contains("data-webui-resource=\"light-card\""),
10670            "returning from the nested Shadow root must restore the outer frame's delivered set"
10671        );
10672    }
10673
10674    #[test]
10675    fn routed_document_styles_are_hoisted_for_the_active_chain() {
10676        let route = WebUiFragmentRoute {
10677            path: "/".to_string(),
10678            fragment_id: "app-shell".to_string(),
10679            children: vec![
10680                WebUiFragmentRoute {
10681                    path: String::new(),
10682                    fragment_id: "dashboard-page".to_string(),
10683                    exact: true,
10684                    ..Default::default()
10685                },
10686                WebUiFragmentRoute {
10687                    path: "details".to_string(),
10688                    fragment_id: "detail-page".to_string(),
10689                    exact: true,
10690                    ..Default::default()
10691                },
10692            ],
10693            ..Default::default()
10694        };
10695        let fragments = HashMap::from([
10696            (
10697                "index.html".to_string(),
10698                FragmentList {
10699                    fragments: vec![
10700                        WebUIFragment::raw("<html><head>"),
10701                        structural_fragment("head_start"),
10702                        structural_fragment("head_end"),
10703                        WebUIFragment::raw("</head><body>"),
10704                        structural_fragment("body_start"),
10705                        WebUIFragment::boundary(0, "index.html", "boundary-0", None),
10706                        WebUIFragment::route_from(route),
10707                        WebUIFragment::boundary_end(0),
10708                        structural_fragment("body_end"),
10709                        WebUIFragment::raw("</body></html>"),
10710                    ],
10711                    contains_boundary: true,
10712                },
10713            ),
10714            (
10715                "app-shell".to_string(),
10716                FragmentList {
10717                    fragments: vec![
10718                        WebUIFragment::raw("<header>App</header>"),
10719                        WebUIFragment::outlet(),
10720                    ],
10721                    contains_boundary: false,
10722                },
10723            ),
10724            (
10725                "dashboard-page".to_string(),
10726                FragmentList {
10727                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10728                    contains_boundary: false,
10729                },
10730            ),
10731            (
10732                "detail-page".to_string(),
10733                FragmentList {
10734                    fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
10735                    contains_boundary: false,
10736                },
10737            ),
10738        ]);
10739        let mut protocol = WebUIProtocol::new(fragments);
10740        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10741        for tag in ["app-shell", "dashboard-page", "detail-page"] {
10742            let component = protocol.components.entry(tag.to_string()).or_default();
10743            component.css_href = format!("/{tag}.css");
10744            component.template_json = r#"{"h":""}"#.to_string();
10745        }
10746        protocol.populate_style_closures(&["index.html"]);
10747
10748        let mut writer = TestWriter::new();
10749        handle(
10750            &protocol,
10751            &test_json!({}),
10752            &RenderOptions::new("index.html", "/"),
10753            &mut writer,
10754        )
10755        .unwrap();
10756        let html = writer.get_content();
10757
10758        assert_eq!(
10759            html.matches(r#"data-webui-resource="app-shell""#).count(),
10760            1,
10761            "the matched route shell must install once: {html}"
10762        );
10763        assert_eq!(
10764            html.matches(r#"data-webui-resource="dashboard-page""#)
10765                .count(),
10766            1,
10767            "the matched child route must install once: {html}"
10768        );
10769        assert!(
10770            !html.contains(r#"data-webui-resource="detail-page""#),
10771            "inactive route CSS must not be delivered: {html}"
10772        );
10773        let head_end = html.find("</head>").expect("head close");
10774        let head = &html[..head_end];
10775        for (resource, href) in [
10776            ("app-shell", "/app-shell.css"),
10777            ("dashboard-page", "/dashboard-page.css"),
10778        ] {
10779            let encoded_href = crate::html_encode::encode_safe(href);
10780            assert!(
10781                head.contains(&format!(
10782                    r#"<link rel="stylesheet" href="{encoded_href}" data-webui-resource="{resource}" data-webui-strategy="link">"#
10783                )),
10784                "Document-targeted route CSS must be render-blocking in head: {html}"
10785            );
10786            assert!(
10787                !head.contains(&format!(
10788                    r#"<link rel="preload" as="style" href="{encoded_href}">"#
10789                )),
10790                "a hoisted stylesheet must not also be preloaded: {html}"
10791            );
10792        }
10793        assert!(
10794            !head.contains("detail-page.css"),
10795            "inactive route CSS must not be preloaded: {html}"
10796        );
10797        let app_style = html
10798            .find(r#"data-webui-resource="app-shell""#)
10799            .expect("app style");
10800        let dashboard_style = html
10801            .find(r#"data-webui-resource="dashboard-page""#)
10802            .expect("dashboard style");
10803        let app_host = html.find("<app-shell").expect("app route host");
10804        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10805        assert!(
10806            app_style < dashboard_style
10807                && dashboard_style < head_end
10808                && head_end < app_host
10809                && app_host < dashboard_host,
10810            "matched Document route closures must be hoisted in chain order: {html}"
10811        );
10812
10813        let protocol = Protocol::new(protocol);
10814        let mut streamed = FlushTestWriter::default();
10815        WebUIHandler::new()
10816            .render_streaming(
10817                &protocol,
10818                &test_json!({}),
10819                &RenderOptions::new("index.html", "/"),
10820                &mut streamed,
10821            )
10822            .unwrap();
10823        let streamed_head_end = streamed
10824            .output
10825            .find("</head>")
10826            .expect("streamed head close");
10827        for resource in ["app-shell", "dashboard-page"] {
10828            let marker = format!(r#"data-webui-resource="{resource}""#);
10829            let position = streamed.output.find(&marker).expect("streamed route style");
10830            assert!(
10831                position < streamed_head_end,
10832                "streaming must hoist {resource} into head: {}",
10833                streamed.output
10834            );
10835            assert_eq!(
10836                streamed.output.matches(&marker).count(),
10837                1,
10838                "streaming must not emit {resource} twice: {}",
10839                streamed.output
10840            );
10841        }
10842    }
10843
10844    #[test]
10845    fn bundled_routed_document_stylesheet_is_hoisted_once() {
10846        let route = WebUiFragmentRoute {
10847            path: "/".to_string(),
10848            fragment_id: "dashboard-page".to_string(),
10849            exact: true,
10850            ..Default::default()
10851        };
10852        let mut protocol = WebUIProtocol::new(HashMap::from([
10853            (
10854                "index.html".to_string(),
10855                FragmentList {
10856                    fragments: vec![
10857                        WebUIFragment::raw("<html><head>"),
10858                        structural_fragment("head_start"),
10859                        structural_fragment("head_end"),
10860                        WebUIFragment::raw("</head><body>"),
10861                        WebUIFragment::route_from(route),
10862                        structural_fragment("body_end"),
10863                        WebUIFragment::raw("</body></html>"),
10864                    ],
10865                    contains_boundary: false,
10866                },
10867            ),
10868            (
10869                "dashboard-page".to_string(),
10870                FragmentList {
10871                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10872                    contains_boundary: false,
10873                },
10874            ),
10875        ]));
10876        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10877        for tag in ["dashboard-page", "summary-card", "activity-list"] {
10878            protocol
10879                .components
10880                .entry(tag.to_string())
10881                .or_default()
10882                .css_href = format!("/{tag}.css");
10883        }
10884        protocol.style_closures.insert(
10885            "index.html".to_string(),
10886            webui_protocol::ComponentStyleClosure::default(),
10887        );
10888        protocol.style_closures.insert(
10889            "dashboard-page".to_string(),
10890            webui_protocol::ComponentStyleClosure {
10891                component_tags: vec![
10892                    "dashboard-page".to_string(),
10893                    "summary-card".to_string(),
10894                    "activity-list".to_string(),
10895                ],
10896                style_chunks: vec![0],
10897            },
10898        );
10899        protocol.style_chunks.push(webui_protocol::StyleChunk {
10900            name: "_chunk-dashboard-page-3".to_string(),
10901            css: String::new(),
10902            css_href: "/_chunk-dashboard-page-3.css".to_string(),
10903            component_tags: vec![
10904                "dashboard-page".to_string(),
10905                "summary-card".to_string(),
10906                "activity-list".to_string(),
10907            ],
10908        });
10909
10910        let mut writer = TestWriter::new();
10911        handle(
10912            &protocol,
10913            &test_json!({}),
10914            &RenderOptions::new("index.html", "/"),
10915            &mut writer,
10916        )
10917        .unwrap();
10918        let html = writer.get_content();
10919        let head_end = html.find("</head>").expect("head close");
10920        let marker = r#"data-webui-resource="_chunk-dashboard-page-3""#;
10921        let style = html.find(marker).expect("bundled route stylesheet");
10922
10923        assert!(
10924            style < head_end,
10925            "bundled route CSS must be in head: {html}"
10926        );
10927        assert_eq!(
10928            html.matches(marker).count(),
10929            1,
10930            "the route host must not emit the hoisted chunk again: {html}"
10931        );
10932        let bundled_href = crate::html_encode::encode_safe("/_chunk-dashboard-page-3.css");
10933        assert!(
10934            !html.contains(&format!(
10935                r#"<link rel="preload" as="style" href="{bundled_href}">"#
10936            )),
10937            "the applied bundled stylesheet must not also be preloaded: {html}"
10938        );
10939    }
10940
10941    #[test]
10942    fn routed_document_inline_styles_are_hoisted() {
10943        for strategy in [
10944            webui_protocol::CssStrategy::Style,
10945            webui_protocol::CssStrategy::Module,
10946        ] {
10947            let route = WebUiFragmentRoute {
10948                path: "/".to_string(),
10949                fragment_id: "dashboard-page".to_string(),
10950                exact: true,
10951                ..Default::default()
10952            };
10953            let mut protocol = WebUIProtocol::new(HashMap::from([
10954                (
10955                    "index.html".to_string(),
10956                    FragmentList {
10957                        fragments: vec![
10958                            WebUIFragment::raw("<html><head>"),
10959                            structural_fragment("head_start"),
10960                            structural_fragment("head_end"),
10961                            WebUIFragment::raw("</head><body>"),
10962                            WebUIFragment::route_from(route),
10963                            structural_fragment("body_end"),
10964                            WebUIFragment::raw("</body></html>"),
10965                        ],
10966                        contains_boundary: false,
10967                    },
10968                ),
10969                (
10970                    "dashboard-page".to_string(),
10971                    FragmentList {
10972                        fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10973                        contains_boundary: false,
10974                    },
10975                ),
10976            ]));
10977            protocol.set_css_strategy(strategy);
10978            protocol
10979                .components
10980                .entry("dashboard-page".to_string())
10981                .or_default()
10982                .css = ".dashboard{display:grid}".to_string();
10983            protocol.populate_style_closures(&["index.html"]);
10984
10985            let mut writer = TestWriter::new();
10986            handle(
10987                &protocol,
10988                &test_json!({}),
10989                &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10990                &mut writer,
10991            )
10992            .unwrap();
10993            let html = writer.get_content();
10994            let head_end = html.find("</head>").expect("head close");
10995            let marker = r#"data-webui-resource="dashboard-page""#;
10996            let style = html.find(marker).expect("route style fallback");
10997            let host = html.find("<dashboard-page").expect("dashboard route host");
10998            let strategy_name = if strategy == webui_protocol::CssStrategy::Module {
10999                "module"
11000            } else {
11001                "style"
11002            };
11003            let style_marker = format!(
11004                r#"data-webui-resource="dashboard-page" data-webui-strategy="{strategy_name}""#
11005            );
11006
11007            assert!(
11008                style < head_end && head_end < host,
11009                "{strategy:?} route CSS must be applied in head before its host: {html}"
11010            );
11011            assert_eq!(
11012                html.matches(&style_marker).count(),
11013                1,
11014                "{strategy:?} route CSS must not be emitted twice: {html}"
11015            );
11016        }
11017    }
11018
11019    #[test]
11020    fn light_route_inside_static_shadow_root_is_only_preloaded_in_head() {
11021        let route = WebUiFragmentRoute {
11022            path: "/".to_string(),
11023            fragment_id: "dashboard-page".to_string(),
11024            exact: true,
11025            ..Default::default()
11026        };
11027        let mut protocol = WebUIProtocol::new(HashMap::from([
11028            (
11029                "index.html".to_string(),
11030                FragmentList {
11031                    fragments: vec![
11032                        WebUIFragment::raw("<html><head>"),
11033                        structural_fragment("head_start"),
11034                        structural_fragment("head_end"),
11035                        WebUIFragment::raw("</head><body><outer-box>"),
11036                        WebUIFragment::component("outer-box"),
11037                        WebUIFragment::raw("</outer-box></body></html>"),
11038                    ],
11039                    contains_boundary: false,
11040                },
11041            ),
11042            (
11043                "outer-box".to_string(),
11044                FragmentList {
11045                    fragments: vec![
11046                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11047                        structural_fragment("shadow_styles:outer-box"),
11048                        WebUIFragment::route_from(route),
11049                        WebUIFragment::raw("</template>"),
11050                    ],
11051                    contains_boundary: false,
11052                },
11053            ),
11054            (
11055                "dashboard-page".to_string(),
11056                FragmentList {
11057                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11058                    contains_boundary: false,
11059                },
11060            ),
11061        ]));
11062        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11063        for tag in ["outer-box", "dashboard-page"] {
11064            protocol
11065                .components
11066                .entry(tag.to_string())
11067                .or_default()
11068                .css_href = format!("/{tag}.css");
11069        }
11070        protocol
11071            .components
11072            .get_mut("outer-box")
11073            .expect("outer-box component")
11074            .uses_shadow_dom = true;
11075        protocol.populate_style_closures(&["index.html"]);
11076
11077        let mut writer = TestWriter::new();
11078        handle(
11079            &protocol,
11080            &test_json!({}),
11081            &RenderOptions::new("index.html", "/"),
11082            &mut writer,
11083        )
11084        .unwrap();
11085        let html = writer.get_content();
11086        let head_end = html.find("</head>").expect("head close");
11087        let head = &html[..head_end];
11088        let dashboard_style = html
11089            .find(r#"data-webui-resource="dashboard-page""#)
11090            .expect("tree-local dashboard stylesheet");
11091        let shadow_start = html
11092            .find("<template shadowrootmode=\"open\">")
11093            .expect("Shadow template");
11094        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
11095        let dashboard_href = crate::html_encode::encode_safe("/dashboard-page.css");
11096
11097        assert!(
11098            head.contains(&format!(
11099                r#"<link rel="preload" as="style" href="{dashboard_href}">"#
11100            )),
11101            "Shadow-targeted route CSS must start loading from head: {html}"
11102        );
11103        assert!(
11104            !head.contains(r#"data-webui-resource="dashboard-page""#),
11105            "Shadow-targeted route CSS must not be applied to Document: {html}"
11106        );
11107        assert!(
11108            shadow_start < dashboard_style && dashboard_style < dashboard_host,
11109            "the applying stylesheet must remain inside the owning ShadowRoot: {html}"
11110        );
11111    }
11112
11113    #[test]
11114    fn isolated_shadow_entry_does_not_emit_document_preloads() {
11115        let route = WebUiFragmentRoute {
11116            path: "/".to_string(),
11117            fragment_id: "dashboard-page".to_string(),
11118            exact: true,
11119            ..Default::default()
11120        };
11121        let mut protocol = WebUIProtocol::new(HashMap::from([
11122            (
11123                "outer-box".to_string(),
11124                FragmentList {
11125                    fragments: vec![
11126                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11127                        structural_fragment("shadow_styles:outer-box"),
11128                        WebUIFragment::route_from(route),
11129                        WebUIFragment::raw("</template>"),
11130                    ],
11131                    contains_boundary: false,
11132                },
11133            ),
11134            (
11135                "dashboard-page".to_string(),
11136                FragmentList {
11137                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11138                    contains_boundary: false,
11139                },
11140            ),
11141        ]));
11142        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11143        for tag in ["outer-box", "dashboard-page"] {
11144            protocol
11145                .components
11146                .entry(tag.to_string())
11147                .or_default()
11148                .css_href = format!("/{tag}.css");
11149        }
11150        protocol
11151            .components
11152            .get_mut("outer-box")
11153            .expect("outer-box component")
11154            .uses_shadow_dom = true;
11155        protocol.populate_style_closures(&["outer-box"]);
11156
11157        let mut writer = TestWriter::new();
11158        handle(
11159            &protocol,
11160            &test_json!({}),
11161            &RenderOptions::new("outer-box", "/"),
11162            &mut writer,
11163        )
11164        .unwrap();
11165        let html = writer.get_content();
11166
11167        assert!(
11168            html.starts_with("<template shadowrootmode=\"open\">"),
11169            "isolated component output must not be prefixed with document metadata: {html}"
11170        );
11171        assert!(
11172            !html.contains(r#"<link rel="preload""#),
11173            "an isolated Shadow entry has no Document head for preloads: {html}"
11174        );
11175        assert!(
11176            html.contains(r#"data-webui-resource="dashboard-page""#),
11177            "the route stylesheet must still install inside the ShadowRoot: {html}"
11178        );
11179    }
11180
11181    #[test]
11182    fn routed_shadow_styles_stay_tree_local_and_deduplicate_static_resources() {
11183        let route = WebUiFragmentRoute {
11184            path: "/".to_string(),
11185            fragment_id: "app-shell".to_string(),
11186            children: vec![
11187                WebUiFragmentRoute {
11188                    path: String::new(),
11189                    fragment_id: "dashboard-page".to_string(),
11190                    exact: true,
11191                    ..Default::default()
11192                },
11193                WebUiFragmentRoute {
11194                    path: "details".to_string(),
11195                    fragment_id: "detail-page".to_string(),
11196                    exact: true,
11197                    ..Default::default()
11198                },
11199            ],
11200            ..Default::default()
11201        };
11202        let fragments = HashMap::from([
11203            (
11204                "index.html".to_string(),
11205                FragmentList {
11206                    fragments: vec![
11207                        WebUIFragment::raw("<html><head>"),
11208                        structural_fragment("head_start"),
11209                        structural_fragment("head_end"),
11210                        WebUIFragment::raw("</head><body>"),
11211                        structural_fragment("body_start"),
11212                        WebUIFragment::boundary(0, "index.html", "boundary-0", None),
11213                        WebUIFragment::route_from(route),
11214                        WebUIFragment::boundary_end(0),
11215                        structural_fragment("body_end"),
11216                        WebUIFragment::raw("</body></html>"),
11217                    ],
11218                    contains_boundary: true,
11219                },
11220            ),
11221            (
11222                "app-shell".to_string(),
11223                FragmentList {
11224                    fragments: vec![
11225                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11226                        structural_fragment("shadow_styles:app-shell"),
11227                        WebUIFragment::raw("<shared-card"),
11228                        structural_fragment("streaming_root:shared-card"),
11229                        WebUIFragment::raw(">"),
11230                        WebUIFragment::component("shared-card"),
11231                        WebUIFragment::raw("</shared-card>"),
11232                        WebUIFragment::outlet(),
11233                        WebUIFragment::raw("</template>"),
11234                    ],
11235                    contains_boundary: false,
11236                },
11237            ),
11238            (
11239                "dashboard-page".to_string(),
11240                FragmentList {
11241                    fragments: vec![
11242                        WebUIFragment::raw("<shared-card"),
11243                        structural_fragment("streaming_root:shared-card"),
11244                        WebUIFragment::raw(">"),
11245                        WebUIFragment::component("shared-card"),
11246                        WebUIFragment::raw("</shared-card><main>Dashboard</main>"),
11247                    ],
11248                    contains_boundary: false,
11249                },
11250            ),
11251            (
11252                "detail-page".to_string(),
11253                FragmentList {
11254                    fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
11255                    contains_boundary: false,
11256                },
11257            ),
11258            (
11259                "shared-card".to_string(),
11260                FragmentList {
11261                    fragments: vec![WebUIFragment::raw("<p>Shared</p>")],
11262                    contains_boundary: false,
11263                },
11264            ),
11265        ]);
11266        let mut protocol = WebUIProtocol::new(fragments);
11267        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11268        for tag in ["app-shell", "dashboard-page", "detail-page", "shared-card"] {
11269            let component = protocol.components.entry(tag.to_string()).or_default();
11270            component.css = format!(".{tag}{{display:block}}");
11271            component.template_json = r#"{"h":""}"#.to_string();
11272        }
11273        protocol
11274            .components
11275            .get_mut("app-shell")
11276            .expect("app shell component")
11277            .uses_shadow_dom = true;
11278        protocol.populate_style_closures(&["index.html"]);
11279
11280        let mut writer = TestWriter::new();
11281        handle(
11282            &protocol,
11283            &test_json!({}),
11284            &RenderOptions::new("index.html", "/"),
11285            &mut writer,
11286        )
11287        .unwrap();
11288        let html = writer.get_content();
11289
11290        for resource in ["app-shell", "shared-card", "dashboard-page"] {
11291            assert_eq!(
11292                html.matches(&format!(r#"data-webui-resource="{resource}""#))
11293                    .count(),
11294                1,
11295                "{resource} must install once in the active ShadowRoot: {html}"
11296            );
11297        }
11298        assert!(
11299            !html.contains(r#"data-webui-resource="detail-page""#),
11300            "inactive route CSS must not enter the ShadowRoot: {html}"
11301        );
11302        let shadow_start = html
11303            .find("<template shadowrootmode=\"open\">")
11304            .expect("Shadow template");
11305        let dashboard_style = html
11306            .find(r#"data-webui-resource="dashboard-page""#)
11307            .expect("dashboard style");
11308        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
11309        let shadow_end = html.find("</template>").expect("Shadow template close");
11310        assert!(
11311            shadow_start < dashboard_style
11312                && dashboard_style < dashboard_host
11313                && dashboard_host < shadow_end,
11314            "active route CSS must precede its generated host inside the owning ShadowRoot: {html}"
11315        );
11316
11317        let protocol = Protocol::new(protocol);
11318        let mut streamed = FlushTestWriter::default();
11319        WebUIHandler::new()
11320            .render_streaming(
11321                &protocol,
11322                &test_json!({}),
11323                &RenderOptions::new("index.html", "/"),
11324                &mut streamed,
11325            )
11326            .unwrap();
11327        for resource in ["app-shell", "shared-card"] {
11328            assert_eq!(
11329                streamed
11330                    .output
11331                    .matches(&format!(r#"data-webui-resource="{resource}""#))
11332                    .count(),
11333                1,
11334                "streaming must preserve tree-local delivery for {resource}: {}",
11335                streamed.output
11336            );
11337        }
11338        assert!(
11339            !streamed
11340                .output
11341                .contains(r#"data-webui-resource="dashboard-page""#),
11342            "route CSS is carried by the streamed checkpoint rather than installed before the route host: {}",
11343            streamed.output
11344        );
11345        assert!(
11346            streamed
11347                .output
11348                .contains(r#""dashboard-page":{"css":".dashboard-page{display:block}""#)
11349                && streamed
11350                    .output
11351                    .contains(r#""dashboard-page":["dashboard-page","shared-card"]"#),
11352            "streamed route CSS metadata must preserve tree ownership and cascade order: {}",
11353            streamed.output
11354        );
11355        assert!(
11356            !streamed
11357                .output
11358                .contains(r#"data-webui-resource="detail-page""#),
11359            "streaming must omit inactive route CSS: {}",
11360            streamed.output
11361        );
11362    }
11363
11364    #[test]
11365    fn document_styles_follow_doctype_before_headless_document_content() {
11366        for entry_fragments in [
11367            vec![
11368                WebUIFragment::raw("<!DOCTYPE html><html><body>"),
11369                structural_fragment("body_start"),
11370                WebUIFragment::raw("<my-card>"),
11371                WebUIFragment::component("my-card"),
11372                WebUIFragment::raw("</my-card></body></html>"),
11373            ],
11374            vec![
11375                WebUIFragment::raw("<!doctype html><html><my-card>"),
11376                WebUIFragment::component("my-card"),
11377                WebUIFragment::raw("</my-card></html>"),
11378            ],
11379        ] {
11380            let mut protocol = WebUIProtocol::new(HashMap::from([
11381                (
11382                    "index.html".to_string(),
11383                    FragmentList {
11384                        fragments: entry_fragments,
11385                        contains_boundary: false,
11386                    },
11387                ),
11388                (
11389                    "my-card".to_string(),
11390                    FragmentList {
11391                        fragments: vec![WebUIFragment::raw("<p>card</p>")],
11392                        contains_boundary: false,
11393                    },
11394                ),
11395            ]));
11396            protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11397            let component = protocol
11398                .components
11399                .entry("my-card".to_string())
11400                .or_default();
11401            component.css = ".card{color:red}".to_string();
11402            component.uses_shadow_dom = false;
11403            protocol.populate_style_closures(&["index.html"]);
11404
11405            let mut writer = TestWriter::new();
11406            handle(
11407                &protocol,
11408                &test_json!({}),
11409                &RenderOptions::new("index.html", "/"),
11410                &mut writer,
11411            )
11412            .expect("render");
11413            let html = writer.get_content();
11414
11415            assert!(
11416                html.to_ascii_lowercase().starts_with("<!doctype html>"),
11417                "the doctype must remain the first document token: {html}"
11418            );
11419            let style = html.find("data-webui-resource=\"my-card\"").expect("style");
11420            let document = html.find("<html").expect("document root");
11421            let component = html.find("<my-card>").expect("component host");
11422            assert!(
11423                style < document && style < component,
11424                "headless document styles must precede document content: {html}"
11425            );
11426        }
11427    }
11428
11429    #[test]
11430    fn authored_shadow_component_can_render_as_entry_fragment() {
11431        let mut protocol = WebUIProtocol::new(HashMap::from([(
11432            "my-card".to_string(),
11433            FragmentList {
11434                fragments: vec![
11435                    WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11436                    structural_fragment("shadow_styles:my-card"),
11437                    WebUIFragment::raw("<p>card</p></template>"),
11438                ],
11439                contains_boundary: false,
11440            },
11441        )]));
11442        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11443        let component = protocol
11444            .components
11445            .entry("my-card".to_string())
11446            .or_default();
11447        component.css = ".card{color:red}".to_string();
11448        component.uses_shadow_dom = true;
11449        protocol.populate_style_closures(&["my-card"]);
11450
11451        let mut writer = TestWriter::new();
11452        handle(
11453            &protocol,
11454            &test_json!({}),
11455            &RenderOptions::new("my-card", "/"),
11456            &mut writer,
11457        )
11458        .expect("render");
11459        let html = writer.get_content();
11460
11461        assert!(html.starts_with("<template shadowrootmode=\"open\"><style "));
11462        assert_eq!(html.matches("data-webui-resource=\"my-card\"").count(), 1);
11463        assert!(html.ends_with("<p>card</p></template>"));
11464    }
11465
11466    #[test]
11467    fn component_style_closures_escape_mixed_case_style_end_tags() {
11468        for strategy in [
11469            webui_protocol::CssStrategy::Style,
11470            webui_protocol::CssStrategy::Module,
11471        ] {
11472            let mut protocol = WebUIProtocol::new(HashMap::from([
11473                (
11474                    "index.html".to_string(),
11475                    FragmentList {
11476                        fragments: vec![
11477                            WebUIFragment::raw("<html><head>"),
11478                            structural_fragment("head_end"),
11479                            WebUIFragment::raw("</head><body><safe-card>"),
11480                            WebUIFragment::component("safe-card"),
11481                            WebUIFragment::raw("</safe-card></body></html>"),
11482                        ],
11483                        contains_boundary: false,
11484                    },
11485                ),
11486                (
11487                    "safe-card".to_string(),
11488                    FragmentList {
11489                        fragments: vec![WebUIFragment::raw("<p>Safe</p>")],
11490                        contains_boundary: false,
11491                    },
11492                ),
11493            ]));
11494            protocol.set_css_strategy(strategy);
11495            protocol.components.insert(
11496                "safe-card".to_string(),
11497                webui_protocol::ComponentData {
11498                    css: ".safe{content:'</StYlE>'}".to_string(),
11499                    ..Default::default()
11500                },
11501            );
11502            protocol.populate_style_closures(&["index.html"]);
11503            let mut writer = TestWriter::new();
11504
11505            handle(
11506                &protocol,
11507                &test_json!({}),
11508                &RenderOptions::new("index.html", "/"),
11509                &mut writer,
11510            )
11511            .unwrap();
11512
11513            let html = writer.get_content();
11514            assert!(html.contains(".safe{content:'<\\/StYlE>'}"), "{html}");
11515            assert!(!html.contains(".safe{content:'</StYlE>'}"), "{html}");
11516        }
11517    }
11518
11519    #[test]
11520    fn shadow_closure_supports_link_and_module_ssr_paths() {
11521        for strategy in [
11522            webui_protocol::CssStrategy::Link,
11523            webui_protocol::CssStrategy::Module,
11524        ] {
11525            let mut protocol = WebUIProtocol::new(HashMap::from([
11526                (
11527                    "index.html".to_string(),
11528                    FragmentList {
11529                        fragments: vec![
11530                            WebUIFragment::raw("<html><head>".to_string()),
11531                            structural_fragment("head_end"),
11532                            WebUIFragment::raw("</head><body><my-card>".to_string()),
11533                            WebUIFragment::component("my-card"),
11534                            WebUIFragment::raw("</my-card></body></html>".to_string()),
11535                        ],
11536                        contains_boundary: false,
11537                    },
11538                ),
11539                (
11540                    "my-card".to_string(),
11541                    FragmentList {
11542                        fragments: vec![
11543                            WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
11544                            structural_fragment("shadow_styles:my-card"),
11545                            WebUIFragment::raw("<p>card</p></template>".to_string()),
11546                        ],
11547                        contains_boundary: false,
11548                    },
11549                ),
11550            ]));
11551            protocol.set_css_strategy(strategy);
11552            let component = protocol
11553                .components
11554                .entry("my-card".to_string())
11555                .or_default();
11556            component.css = ".card{color:red}".to_string();
11557            component.css_href = "/my-card.css".to_string();
11558            component.uses_shadow_dom = true;
11559            protocol.populate_style_closures(&["index.html"]);
11560
11561            let mut writer = TestWriter::new();
11562            handle(
11563                &protocol,
11564                &test_json!({}),
11565                &RenderOptions::new("index.html", "/"),
11566                &mut writer,
11567            )
11568            .unwrap();
11569            let html = writer.get_content();
11570            match strategy {
11571                webui_protocol::CssStrategy::Link => {
11572                    let escaped_href = crate::html_encode::encode_safe("/my-card.css");
11573                    assert!(html.contains(&format!(
11574                        "<template shadowrootmode=\"open\"><link rel=\"stylesheet\" href=\"{escaped_href}\" data-webui-resource=\"my-card\" data-webui-strategy=\"link\">"
11575                    )));
11576                }
11577                webui_protocol::CssStrategy::Module => {
11578                    assert!(html.contains(
11579                        "<template shadowrootmode=\"open\"><style data-webui-resource=\"my-card\" data-webui-strategy=\"module\">.card{color:red}</style>"
11580                    ));
11581                    assert_eq!(html.matches("<script type=\"importmap\"").count(), 1);
11582                }
11583                webui_protocol::CssStrategy::Style => unreachable!(),
11584            }
11585        }
11586    }
11587
11588    #[test]
11589    fn test_module_preloads_emit_in_head_in_compiler_order() {
11590        // The whole value of these hints is ordering: preloads are issued in
11591        // document order over one connection, so the largest chunk must go
11592        // first. The compiler sorts; the handler must not reorder or dedupe.
11593        let mut fragments = HashMap::new();
11594        fragments.insert(
11595            "index.html".to_string(),
11596            FragmentList {
11597                fragments: vec![
11598                    WebUIFragment::raw(
11599                        r#"<html><head><script type="module" async src="/index.js"></script>"#
11600                            .to_string(),
11601                    ),
11602                    structural_fragment("head_end"),
11603                    WebUIFragment::raw("</head><body>".to_string()),
11604                    structural_fragment("body_end"),
11605                    WebUIFragment::raw("</body></html>".to_string()),
11606                ],
11607                contains_boundary: false,
11608            },
11609        );
11610
11611        let mut protocol = WebUIProtocol::new(fragments);
11612        protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
11613
11614        let state = test_json!({});
11615        let mut writer = TestWriter::new();
11616        handle(
11617            &protocol,
11618            &state,
11619            &RenderOptions::new("index.html", "/"),
11620            &mut writer,
11621        )
11622        .unwrap();
11623
11624        let html = writer.get_content();
11625        let head_end = html.find("</head>").expect("</head> missing");
11626        let head = &html[..head_end];
11627        assert!(
11628            head.contains(
11629                r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
11630            ),
11631            "hints must appear in <head> in the compiler's order: {html}"
11632        );
11633    }
11634
11635    #[test]
11636    fn test_no_module_preloads_emits_nothing() {
11637        let mut fragments = HashMap::new();
11638        fragments.insert(
11639            "index.html".to_string(),
11640            FragmentList {
11641                fragments: vec![
11642                    WebUIFragment::raw("<html><head>".to_string()),
11643                    structural_fragment("head_end"),
11644                    WebUIFragment::raw("</head><body>".to_string()),
11645                    structural_fragment("body_end"),
11646                    WebUIFragment::raw("</body></html>".to_string()),
11647                ],
11648                contains_boundary: false,
11649            },
11650        );
11651        let protocol = WebUIProtocol::new(fragments);
11652
11653        let state = test_json!({});
11654        let mut writer = TestWriter::new();
11655        handle(
11656            &protocol,
11657            &state,
11658            &RenderOptions::new("index.html", "/"),
11659            &mut writer,
11660        )
11661        .unwrap();
11662
11663        assert!(
11664            !writer.get_content().contains("modulepreload"),
11665            "a build without hints must be byte-identical to before"
11666        );
11667    }
11668
11669    #[test]
11670    fn test_link_strategy_head_links_follow_document_order() {
11671        // Regression for #381: Link-strategy <head> CSS <link> tags must be
11672        // emitted in document/traversal order, not alphabetical tag order.
11673        // Document order here is <z-widget> then <a-widget>; an alphabetical
11674        // sort would (incorrectly) place a-widget first.
11675        let mut fragments = HashMap::new();
11676        fragments.insert(
11677            "index.html".to_string(),
11678            FragmentList {
11679                fragments: vec![
11680                    WebUIFragment::raw("<html><head>".to_string()),
11681                    structural_fragment("head_end"),
11682                    WebUIFragment::raw("</head><body><z-widget>".to_string()),
11683                    WebUIFragment::component("z-widget"),
11684                    WebUIFragment::raw("</z-widget><a-widget>".to_string()),
11685                    WebUIFragment::component("a-widget"),
11686                    WebUIFragment::raw("</a-widget>".to_string()),
11687                    structural_fragment("body_end"),
11688                    WebUIFragment::raw("</body></html>".to_string()),
11689                ],
11690                contains_boundary: false,
11691            },
11692        );
11693        fragments.insert(
11694            "z-widget".to_string(),
11695            FragmentList {
11696                fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
11697                contains_boundary: false,
11698            },
11699        );
11700        fragments.insert(
11701            "a-widget".to_string(),
11702            FragmentList {
11703                fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
11704                contains_boundary: false,
11705            },
11706        );
11707
11708        let mut protocol = WebUIProtocol::new(fragments);
11709        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11710
11711        let z = protocol
11712            .components
11713            .entry("z-widget".to_string())
11714            .or_default();
11715        z.css_href = "z-widget.css".to_string();
11716        z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
11717
11718        let a = protocol
11719            .components
11720            .entry("a-widget".to_string())
11721            .or_default();
11722        a.css_href = "a-widget.css".to_string();
11723        a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
11724        protocol.populate_style_closures(&["index.html"]);
11725
11726        let state = test_json!({});
11727        let mut writer = TestWriter::new();
11728
11729        handle(
11730            &protocol,
11731            &state,
11732            &RenderOptions::new("index.html", "/"),
11733            &mut writer,
11734        )
11735        .unwrap();
11736
11737        let html = writer.get_content();
11738        let head_end = html.find("</head>").expect("</head> missing");
11739        let head_section = &html[..head_end];
11740
11741        let z_pos = head_section
11742            .find(r#"<link rel="stylesheet" href="z-widget.css""#)
11743            .expect("z-widget stylesheet link missing from <head>");
11744        let a_pos = head_section
11745            .find(r#"<link rel="stylesheet" href="a-widget.css""#)
11746            .expect("a-widget stylesheet link missing from <head>");
11747
11748        assert!(
11749            z_pos < a_pos,
11750            "CSS <link> tags must follow document order (z-widget before \
11751             a-widget), not alphabetical order: {html}"
11752        );
11753    }
11754
11755    #[test]
11756    fn test_css_module_emitted_in_component_light_dom() {
11757        // CSS module <style> tags are emitted inline in the component's light DOM,
11758        // not in <head>. This keeps SSR output lean — only rendered components
11759        // get their style definitions.
11760        let mut fragments = HashMap::new();
11761        fragments.insert(
11762            "index.html".to_string(),
11763            FragmentList {
11764                fragments: vec![
11765                    WebUIFragment::raw("<html><head>".to_string()),
11766                    structural_fragment("head_end"),
11767                    WebUIFragment::raw("</head><body><my-card>".to_string()),
11768                    WebUIFragment::component("my-card"),
11769                    WebUIFragment::raw("</my-card>".to_string()),
11770                    WebUIFragment::raw("</body></html>".to_string()),
11771                ],
11772                contains_boundary: false,
11773            },
11774        );
11775        fragments.insert(
11776            "my-card".to_string(),
11777            FragmentList {
11778                fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
11779                contains_boundary: false,
11780            },
11781        );
11782
11783        let mut protocol = WebUIProtocol::new(fragments);
11784        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11785        protocol
11786            .components
11787            .entry("my-card".to_string())
11788            .or_default()
11789            .css = "p{color:red}".to_string();
11790        protocol.populate_style_closures(&["index.html"]);
11791        let state = test_json!({});
11792        let mut writer = TestWriter::new();
11793
11794        handle(
11795            &protocol,
11796            &state,
11797            &RenderOptions::new("index.html", "/"),
11798            &mut writer,
11799        )
11800        .unwrap();
11801
11802        let html = writer.get_content();
11803
11804        // CSS module importmap must be INSIDE the component tag (light DOM)
11805        let tag_open = html.find("<my-card>").expect("<my-card> missing");
11806        let css_pos = html
11807            .find(r#"<script type="importmap""#)
11808            .expect("CSS module importmap missing");
11809        let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
11810        assert!(
11811            css_pos > tag_open && css_pos < tag_close,
11812            "CSS module should be inside component light DOM: {html}"
11813        );
11814
11815        // <head> should NOT contain module styles
11816        let head_end = html.find("</head>").expect("</head> missing");
11817        assert!(
11818            css_pos > head_end,
11819            "CSS module should not be in <head>: {html}"
11820        );
11821    }
11822
11823    #[test]
11824    fn styled_protocol_without_closures_is_rejected_for_full_ssr() {
11825        let mut protocol = WebUIProtocol::default();
11826        protocol.fragments = HashMap::from([
11827            (
11828                "index.html".to_string(),
11829                FragmentList {
11830                    fragments: vec![
11831                        WebUIFragment::raw("<legacy-card>"),
11832                        WebUIFragment::component("legacy-card"),
11833                        WebUIFragment::raw("</legacy-card>"),
11834                    ],
11835                    contains_boundary: false,
11836                },
11837            ),
11838            (
11839                "legacy-card".to_string(),
11840                FragmentList {
11841                    fragments: vec![WebUIFragment::raw("<p>Legacy</p>")],
11842                    contains_boundary: false,
11843                },
11844            ),
11845        ]);
11846        protocol.components.insert(
11847            "legacy-card".to_string(),
11848            webui_protocol::ComponentData {
11849                css: ".legacy{color:red}".to_string(),
11850                ..Default::default()
11851            },
11852        );
11853        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11854        let mut writer = TestWriter::new();
11855
11856        let error = handle(
11857            &protocol,
11858            &test_json!({}),
11859            &RenderOptions::new("index.html", "/"),
11860            &mut writer,
11861        )
11862        .expect_err("styled protocols require closure metadata");
11863        assert!(error
11864            .to_string()
11865            .contains("component style closure metadata is required"));
11866    }
11867
11868    #[test]
11869    fn test_css_module_emitted_for_route_components() {
11870        // Route components get CSS modules emitted inline in their light DOM.
11871        let template = r#"<h1>Dashboard</h1>"#;
11872
11873        let mut fragments = HashMap::new();
11874        fragments.insert(
11875            "index.html".to_string(),
11876            FragmentList {
11877                fragments: vec![
11878                    WebUIFragment::raw("<html><head>".to_string()),
11879                    structural_fragment("head_end"),
11880                    WebUIFragment::raw("</head><body>".to_string()),
11881                    WebUIFragment::route("/", "dash-page"),
11882                    structural_fragment("body_end"),
11883                    WebUIFragment::raw("</body></html>".to_string()),
11884                ],
11885                contains_boundary: false,
11886            },
11887        );
11888        fragments.insert(
11889            "dash-page".to_string(),
11890            FragmentList {
11891                fragments: vec![WebUIFragment::raw(template.to_string())],
11892                contains_boundary: false,
11893            },
11894        );
11895
11896        let mut protocol = WebUIProtocol::new(fragments);
11897        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11898        let comp = protocol
11899            .components
11900            .entry("dash-page".to_string())
11901            .or_default();
11902        comp.css = "h1{font-size:2rem}".to_string();
11903        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
11904        protocol.populate_style_closures(&["index.html"]);
11905        let state = test_json!({});
11906        let mut writer = TestWriter::new();
11907
11908        handle(
11909            &protocol,
11910            &state,
11911            &RenderOptions::new("index.html", "/"),
11912            &mut writer,
11913        )
11914        .unwrap();
11915
11916        let html = writer.get_content();
11917
11918        assert!(
11919            html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
11920            "Route component should have CSS module importmap with data: URI: {html}"
11921        );
11922        assert!(
11923            html.contains("<h1>Dashboard</h1>"),
11924            "Route component should render content: {html}"
11925        );
11926        let fallback = html
11927            .find(r#"data-webui-resource="dash-page" data-webui-strategy="module""#)
11928            .expect("route Module fallback");
11929        let host_open = html.find("<dash-page>").expect("route host");
11930        let content = html.find("<h1>Dashboard</h1>").expect("route content");
11931        assert!(
11932            fallback < host_open && host_open < content,
11933            "the Module fallback must precede the generated Light route host: {html}"
11934        );
11935        // The importmap belongs to the component's own Light DOM, never to the
11936        // route element. The router only treats `<link>`/`<style>` markers as
11937        // route-owned styles, so an importmap emitted as a direct route child
11938        // would be cleared on navigation and mistaken for the mounted
11939        // component. Keep it inside the host so that can never happen.
11940        let importmap = html
11941            .find(r#"<script type="importmap""#)
11942            .expect("route CSS module importmap");
11943        assert!(
11944            host_open < importmap,
11945            "the CSS module importmap must live inside the route host, not as a route child: {html}"
11946        );
11947    }
11948
11949    #[test]
11950    fn test_head_css_link_skipped_for_components_without_css() {
11951        // Regression: components without CSS files must not get <link> tags
11952        // in <head>, otherwise the browser requests a 404.
11953        let mut fragments = HashMap::new();
11954        fragments.insert(
11955            "index.html".to_string(),
11956            FragmentList {
11957                fragments: vec![
11958                    WebUIFragment::raw("<html><head>".to_string()),
11959                    structural_fragment("head_end"),
11960                    WebUIFragment::raw("</head><body>".to_string()),
11961                    WebUIFragment::component("has-css"),
11962                    WebUIFragment::component("no-css"),
11963                    structural_fragment("body_end"),
11964                    WebUIFragment::raw("</body></html>".to_string()),
11965                ],
11966                contains_boundary: false,
11967            },
11968        );
11969        fragments.insert(
11970            "has-css".to_string(),
11971            FragmentList {
11972                fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
11973                contains_boundary: false,
11974            },
11975        );
11976        fragments.insert(
11977            "no-css".to_string(),
11978            FragmentList {
11979                fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
11980                contains_boundary: false,
11981            },
11982        );
11983
11984        let mut protocol = WebUIProtocol::new(fragments);
11985        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11986
11987        // Only has-css has an external stylesheet (Link strategy)
11988        protocol
11989            .components
11990            .entry("has-css".to_string())
11991            .or_default()
11992            .css_href = "has-css.css".to_string();
11993        protocol
11994            .components
11995            .entry("no-css".to_string())
11996            .or_default()
11997            .template_json = r#"{"h":"<p>plain</p>"}"#.to_string();
11998        protocol.populate_style_closures(&["index.html"]);
11999
12000        let state = test_json!({});
12001        let mut writer = TestWriter::new();
12002
12003        let handler = WebUIHandler::with_plugin(|| {
12004            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12005        });
12006        handler
12007            .handle(
12008                &protocol,
12009                &state,
12010                &RenderOptions::new("index.html", "/"),
12011                &mut writer,
12012            )
12013            .unwrap();
12014
12015        let html = writer.get_content();
12016        assert!(
12017            html.contains(r#"<link rel="stylesheet" href="has-css.css""#),
12018            "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
12019        );
12020        assert!(
12021            !html.contains("no-css.css"),
12022            "Component without CSS must NOT get a <link> in <head>: {html}"
12023        );
12024    }
12025
12026    #[test]
12027    fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
12028        // Simulates a page where app-shell renders cart-panel, but cart-panel
12029        // contains an <if> block with product-card inside. When the condition
12030        // is false (empty cart), product-card is NOT rendered — but it IS
12031        // reachable from the fragment graph. Its template metadata and CSS module
12032        // definition must be in the output so the client can mount it when
12033        // the <if> flips true. However, its bit must NOT be set in the
12034        // inventory — the inventory tracks what was actually rendered.
12035        let mut fragments = HashMap::new();
12036        fragments.insert(
12037            "index.html".to_string(),
12038            FragmentList {
12039                fragments: vec![
12040                    WebUIFragment::raw("<html><head>".to_string()),
12041                    structural_fragment("head_end"),
12042                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
12043                    WebUIFragment::component("app-shell"),
12044                    WebUIFragment::raw("</app-shell>".to_string()),
12045                    structural_fragment("body_end"),
12046                    WebUIFragment::raw("</body></html>".to_string()),
12047                ],
12048                contains_boundary: false,
12049            },
12050        );
12051        // app-shell contains a cart panel
12052        fragments.insert(
12053            "app-shell".to_string(),
12054            FragmentList {
12055                fragments: vec![
12056                    WebUIFragment::raw("<div>Shell</div>".to_string()),
12057                    WebUIFragment::component("cart-panel"),
12058                ],
12059                contains_boundary: false,
12060            },
12061        );
12062        // cart-panel has an <if> block containing product-card
12063        fragments.insert(
12064            "cart-panel".to_string(),
12065            FragmentList {
12066                fragments: vec![
12067                    WebUIFragment::raw("<aside>".to_string()),
12068                    WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
12069                    WebUIFragment::raw("</aside>".to_string()),
12070                ],
12071                contains_boundary: false,
12072            },
12073        );
12074        // cart-items (if block body) contains product-card
12075        fragments.insert(
12076            "cart-items".to_string(),
12077            FragmentList {
12078                fragments: vec![WebUIFragment::component("product-card")],
12079                contains_boundary: false,
12080            },
12081        );
12082        fragments.insert(
12083            "product-card".to_string(),
12084            FragmentList {
12085                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
12086                contains_boundary: false,
12087            },
12088        );
12089
12090        let mut protocol = WebUIProtocol::new(fragments);
12091        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12092        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12093        for name in ["app-shell", "cart-panel", "product-card"] {
12094            let comp = protocol.components.entry(name.to_string()).or_default();
12095            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
12096            comp.css = format!(".{name}{{display:block}}");
12097            if name == "cart-panel" {
12098                comp.hydration_mode = StateProjectionMode::Keys as i32;
12099                comp.hydration_keys = vec!["hasItems".to_string()];
12100            }
12101            if name == "product-card" {
12102                comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
12103            }
12104        }
12105        protocol.populate_style_closures(&["index.html"]);
12106
12107        // Render with hasItems=false — product-card should NOT be rendered
12108        let state = test_json!({ "hasItems": false });
12109        let mut writer = TestWriter::new();
12110
12111        let handler = WebUIHandler::with_plugin(|| {
12112            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12113        });
12114        handler
12115            .handle(
12116                &protocol,
12117                &state,
12118                &RenderOptions::new("index.html", "/"),
12119                &mut writer,
12120            )
12121            .unwrap();
12122
12123        let html = writer.get_content();
12124
12125        assert!(
12126            html.contains(r#"<script type="application/json" id="webui-data">"#),
12127            "non-executable SSR metadata should be emitted in the webui-data block: {html}"
12128        );
12129        assert!(
12130            html.contains(r#""state":{"hasItems":false}"#),
12131            "SSR state should live in the JSON data block: {html}"
12132        );
12133        assert!(
12134            html.contains(r#""inventory":"#),
12135            "SSR inventory should live in the JSON data block: {html}"
12136        );
12137        assert!(
12138            !html.contains("window.__webui={\""),
12139            "executable bootstrap must not embed the window.__webui JSON literal: {html}"
12140        );
12141        assert!(
12142            !html.contains(r#"document.getElementById("webui-data")"#),
12143            "SSR must not parse webui-data; client packages own that lazy load: {html}"
12144        );
12145        assert!(
12146            !html.contains("window.__webui=w;"),
12147            "executable bootstrap must not replace existing window.__webui registrations: {html}"
12148        );
12149        assert!(
12150            !html.contains("w.templateFns={\""),
12151            "template function emission must not replace existing templateFns registrations: {html}"
12152        );
12153        assert!(
12154            html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
12155            "template functions should merge into the flat templateFns registry: {html}"
12156        );
12157
12158        // product-card template IS in the output — it's a known component
12159        // whose template must be available for client-side <if> activation.
12160        assert!(
12161            html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
12162            "product-card template should be emitted even when unrendered: {html}"
12163        );
12164
12165        // product-card CSS module IS in the output — reachable components need
12166        // their stylesheet definitions for client-side <if> activation.
12167        assert!(
12168            html.contains(r#""product-card":"data:text/css,"#),
12169            "reachable product-card CSS module importmap should be emitted: {html}"
12170        );
12171
12172        // app-shell and cart-panel SHOULD be in the output (they were rendered)
12173        assert!(
12174            html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
12175            "rendered app-shell template should be emitted: {html}"
12176        );
12177        assert!(
12178            html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
12179            "rendered cart-panel template should be emitted: {html}"
12180        );
12181    }
12182
12183    #[test]
12184    fn component_asset_manifest_falls_back_to_body_start() {
12185        let fragments = HashMap::from([(
12186            "index.html".to_string(),
12187            FragmentList {
12188                fragments: vec![
12189                    WebUIFragment::raw("<body>".to_string()),
12190                    structural_fragment("body_start"),
12191                    WebUIFragment::raw("<main>Body-only host</main></body>".to_string()),
12192                ],
12193                contains_boundary: false,
12194            },
12195        )]);
12196        let mut protocol = WebUIProtocol::new(fragments);
12197        protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
12198            root: "lazy-panel".to_string(),
12199            style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
12200        }];
12201        let mut writer = TestWriter::new();
12202
12203        handle(
12204            &protocol,
12205            &Value::Null,
12206            &RenderOptions::new("index.html", "/"),
12207            &mut writer,
12208        )
12209        .unwrap();
12210
12211        assert_eq!(
12212            writer.get_content(),
12213            r#"<body><script type="application/json" id="webui-component-assets">{"lazy-panel":["/assets/lazy-panel.css"]}</script><main>Body-only host</main></body>"#
12214        );
12215    }
12216
12217    #[test]
12218    fn component_asset_styles_emit_once_when_body_precedes_head() {
12219        let fragments = HashMap::from([(
12220            "index.html".to_string(),
12221            FragmentList {
12222                fragments: vec![
12223                    WebUIFragment::raw("<body>".to_string()),
12224                    structural_fragment("body_start"),
12225                    WebUIFragment::raw("</body><head>".to_string()),
12226                    structural_fragment("head_end"),
12227                    WebUIFragment::raw("</head>".to_string()),
12228                ],
12229                contains_boundary: false,
12230            },
12231        )]);
12232        let mut protocol = WebUIProtocol::new(fragments);
12233        protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
12234            root: "lazy-panel".to_string(),
12235            style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
12236        }];
12237        let mut writer = TestWriter::new();
12238
12239        handle(
12240            &protocol,
12241            &Value::Null,
12242            &RenderOptions::new("index.html", "/"),
12243            &mut writer,
12244        )
12245        .unwrap();
12246
12247        assert_eq!(
12248            writer
12249                .get_content()
12250                .matches(r#"id="webui-component-assets""#)
12251                .count(),
12252            1
12253        );
12254    }
12255
12256    #[test]
12257    fn light_component_asset_styles_apply_at_head_end() {
12258        let fragments = HashMap::from([(
12259            "index.html".to_string(),
12260            FragmentList {
12261                fragments: vec![
12262                    WebUIFragment::raw("<html><head>".to_string()),
12263                    structural_fragment("head_end"),
12264                    WebUIFragment::raw("</head><body></body></html>".to_string()),
12265                ],
12266                contains_boundary: false,
12267            },
12268        )]);
12269        let mut protocol = WebUIProtocol::new(fragments);
12270        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
12271        for root in ["lazy-panel", "secondary-panel"] {
12272            protocol.components.insert(
12273                root.to_string(),
12274                webui_protocol::ComponentData {
12275                    uses_shadow_dom: false,
12276                    ..Default::default()
12277                },
12278            );
12279        }
12280        protocol.component_asset_style_preloads = vec![
12281            ComponentAssetStylePreload {
12282                root: "lazy-panel".to_string(),
12283                style_hrefs: vec![
12284                    "/assets/lazy-panel.css".to_string(),
12285                    "/assets/shared.css".to_string(),
12286                ],
12287            },
12288            ComponentAssetStylePreload {
12289                root: "secondary-panel".to_string(),
12290                style_hrefs: vec![
12291                    "/assets/secondary-panel.css".to_string(),
12292                    "/assets/shared.css".to_string(),
12293                ],
12294            },
12295        ];
12296        let mut writer = TestWriter::new();
12297
12298        handle(
12299            &protocol,
12300            &Value::Null,
12301            &RenderOptions::new("index.html", "/"),
12302            &mut writer,
12303        )
12304        .unwrap();
12305
12306        let html = writer.get_content();
12307        assert_eq!(html.matches(r#"rel="stylesheet""#).count(), 3);
12308        assert_eq!(html.matches("/assets/shared.css").count(), 1);
12309        assert!(!html.contains(COMPONENT_ASSET_MANIFEST_ID));
12310        let links_end = html
12311            .find(r#"<link rel="stylesheet" href="/assets/secondary-panel.css">"#)
12312            .expect("asset stylesheet must be emitted");
12313        let head_end = html.find("</head>").expect("head must close");
12314        assert!(links_end < head_end);
12315    }
12316
12317    // ── CSP nonce on CSS module importmap ───────────────────────────
12318    //
12319    // When `RenderOptions::with_nonce(...)` is set, every inline
12320    // `<script type="importmap">` definition emitted during SSR for a
12321    // component CSS module must include `nonce="VALUE"` so strict CSP
12322    // `script-src 'nonce-...'` policies allow it. The without-nonce case
12323    // is already covered by other CSS module tests (e.g.
12324    // `test_css_module_emitted_for_route_components`).
12325
12326    #[test]
12327    fn test_css_module_emits_nonce_attribute_when_nonce_set() {
12328        // Per-component first-render path (`emit_css_module`).
12329        let template = r#"<h1>Dashboard</h1>"#;
12330
12331        let mut fragments = HashMap::new();
12332        fragments.insert(
12333            "index.html".to_string(),
12334            FragmentList {
12335                fragments: vec![
12336                    WebUIFragment::raw("<html><head>".to_string()),
12337                    structural_fragment("head_end"),
12338                    WebUIFragment::raw("</head><body>".to_string()),
12339                    WebUIFragment::route("/", "dash-page"),
12340                    structural_fragment("body_end"),
12341                    WebUIFragment::raw("</body></html>".to_string()),
12342                ],
12343                contains_boundary: false,
12344            },
12345        );
12346        fragments.insert(
12347            "dash-page".to_string(),
12348            FragmentList {
12349                fragments: vec![WebUIFragment::raw(template.to_string())],
12350                contains_boundary: false,
12351            },
12352        );
12353
12354        let mut protocol = WebUIProtocol::new(fragments);
12355        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12356        let comp = protocol
12357            .components
12358            .entry("dash-page".to_string())
12359            .or_default();
12360        comp.css = "h1{font-size:2rem}".to_string();
12361        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
12362        protocol.populate_style_closures(&["index.html"]);
12363        let state = test_json!({});
12364        let mut writer = TestWriter::new();
12365
12366        handle(
12367            &protocol,
12368            &state,
12369            &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
12370            &mut writer,
12371        )
12372        .unwrap();
12373
12374        let html = writer.get_content();
12375
12376        assert!(
12377            html.contains(
12378                r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="dash-page">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
12379            ),
12380            "CSS module importmap tag should include nonce attribute in canonical order: {html}"
12381        );
12382    }
12383
12384    #[test]
12385    fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
12386        // Body-end emission path for reachable-but-unrendered components
12387        // (the second site touched by the patch). Triggered via a false
12388        // `<if>` block under hydration; requires the WebUI plugin so the
12389        // body_end hook executes.
12390        let mut fragments = HashMap::new();
12391        fragments.insert(
12392            "index.html".to_string(),
12393            FragmentList {
12394                fragments: vec![
12395                    WebUIFragment::raw("<html><head>".to_string()),
12396                    structural_fragment("head_end"),
12397                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
12398                    WebUIFragment::component("app-shell"),
12399                    WebUIFragment::raw("</app-shell>".to_string()),
12400                    structural_fragment("body_end"),
12401                    WebUIFragment::raw("</body></html>".to_string()),
12402                ],
12403                contains_boundary: false,
12404            },
12405        );
12406        fragments.insert(
12407            "app-shell".to_string(),
12408            FragmentList {
12409                fragments: vec![WebUIFragment::if_cond(
12410                    ConditionExpr::identifier("hasItems"),
12411                    "cart-items",
12412                )],
12413                contains_boundary: false,
12414            },
12415        );
12416        fragments.insert(
12417            "cart-items".to_string(),
12418            FragmentList {
12419                fragments: vec![WebUIFragment::component("product-card")],
12420                contains_boundary: false,
12421            },
12422        );
12423        fragments.insert(
12424            "product-card".to_string(),
12425            FragmentList {
12426                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
12427                contains_boundary: false,
12428            },
12429        );
12430
12431        let mut protocol = WebUIProtocol::new(fragments);
12432        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12433        for name in ["app-shell", "product-card"] {
12434            let comp = protocol.components.entry(name.to_string()).or_default();
12435            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
12436            comp.css = format!(".{name}{{display:block}}");
12437        }
12438        protocol.populate_style_closures(&["index.html"]);
12439
12440        // Render with hasItems=false so product-card is reachable but not
12441        // rendered, forcing its CSS module emission through the body_end path.
12442        let state = test_json!({ "hasItems": false });
12443        let mut writer = TestWriter::new();
12444
12445        let handler = WebUIHandler::with_plugin(|| {
12446            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12447        });
12448        handler
12449            .handle(
12450                &protocol,
12451                &state,
12452                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
12453                &mut writer,
12454            )
12455            .unwrap();
12456
12457        let html = writer.get_content();
12458
12459        assert!(
12460            html.contains(
12461                r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="product-card">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
12462            ),
12463            "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
12464        );
12465    }
12466
12467    #[test]
12468    fn projected_state_excludes_non_hydration_keys() -> Result<()> {
12469        let mut fragments = HashMap::new();
12470        fragments.insert(
12471            "index.html".to_string(),
12472            FragmentList {
12473                fragments: vec![
12474                    WebUIFragment::raw("<html><body><style>".to_string()),
12475                    WebUIFragment::signal("tokens.light", true),
12476                    WebUIFragment::raw("</style>".to_string()),
12477                    structural_fragment("body_end"),
12478                    WebUIFragment::raw("</body></html>".to_string()),
12479                ],
12480                contains_boundary: false,
12481            },
12482        );
12483        fragments.insert(
12484            "app-shell".to_string(),
12485            FragmentList {
12486                fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
12487                contains_boundary: false,
12488            },
12489        );
12490        let index_fragments = fragments
12491            .get_mut("index.html")
12492            .expect("index fixture should exist");
12493        index_fragments
12494            .fragments
12495            .insert(1, WebUIFragment::component("app-shell"));
12496        let mut protocol = WebUIProtocol::new(fragments);
12497        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12498        // Only `name` is a hydration key. `tokens` is a server-only field
12499        // (used above to resolve SSR CSS variables) and is NOT in the component
12500        // hydration keys,
12501        // so projection MUST keep it out of the client state block.
12502        protocol.components.insert(
12503            "app-shell".to_string(),
12504            webui_protocol::ComponentData {
12505                hydration_mode: StateProjectionMode::Keys as i32,
12506                hydration_keys: vec!["name".to_string()],
12507                ..Default::default()
12508            },
12509        );
12510        let state = test_json!({
12511            "name": "Alice",
12512            "tokens": {
12513                "light": "--color-brand: red;"
12514            }
12515        });
12516        let mut writer = TestWriter::new();
12517        let handler = WebUIHandler::with_plugin(|| {
12518            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12519        });
12520        handler.handle(
12521            &protocol,
12522            &state,
12523            &RenderOptions::new("index.html", "/"),
12524            &mut writer,
12525        )?;
12526        let output = writer.get_content();
12527
12528        // SSR still reads `tokens` to resolve the inline <style>...
12529        assert!(output.contains("--color-brand: red;"));
12530        // ...but only the hydration key reaches the client state.
12531        assert!(output.contains(r#""name":"Alice""#));
12532        assert!(!output.contains(r#""tokens""#));
12533        Ok(())
12534    }
12535
12536    #[test]
12537    fn full_initial_strategy_preserves_complete_state() -> Result<()> {
12538        let mut fragments = HashMap::new();
12539        fragments.insert(
12540            "index.html".to_string(),
12541            FragmentList {
12542                fragments: vec![
12543                    WebUIFragment::raw("<html><body>"),
12544                    structural_fragment("body_end"),
12545                    WebUIFragment::raw("</body></html>"),
12546                ],
12547                contains_boundary: false,
12548            },
12549        );
12550        let protocol = WebUIProtocol::new(fragments);
12551        let state = test_json!({
12552            "client": "visible",
12553            "serverOnly": "also preserved",
12554        });
12555        let handler = WebUIHandler::with_plugin(|| {
12556            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12557        });
12558        let mut writer = TestWriter::new();
12559        handler.handle(
12560            &protocol,
12561            &state,
12562            &RenderOptions::new("index.html", "/"),
12563            &mut writer,
12564        )?;
12565        let output = writer.get_content();
12566        assert!(output.contains(r#""client":"visible""#));
12567        assert!(output.contains(r#""serverOnly":"also preserved""#));
12568        Ok(())
12569    }
12570
12571    #[test]
12572    fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
12573        let mut fragments = HashMap::new();
12574        fragments.insert(
12575            "index.html".to_string(),
12576            FragmentList {
12577                fragments: vec![
12578                    WebUIFragment::raw("<html><body>"),
12579                    WebUIFragment::component("app-shell"),
12580                    structural_fragment("body_end"),
12581                    WebUIFragment::raw("</body></html>"),
12582                ],
12583                contains_boundary: false,
12584            },
12585        );
12586        fragments.insert(
12587            "app-shell".to_string(),
12588            FragmentList {
12589                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12590                contains_boundary: false,
12591            },
12592        );
12593        let mut protocol = WebUIProtocol::new(fragments);
12594        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12595        protocol.components.insert(
12596            "app-shell".to_string(),
12597            webui_protocol::ComponentData {
12598                hydration_mode: StateProjectionMode::All as i32,
12599                ..Default::default()
12600            },
12601        );
12602        let state = test_json!({
12603            "known": "value",
12604            "possiblyInherited": "must not be dropped",
12605        });
12606        let handler = WebUIHandler::with_plugin(|| {
12607            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12608        });
12609        let mut writer = TestWriter::new();
12610        handler.handle(
12611            &protocol,
12612            &state,
12613            &RenderOptions::new("index.html", "/"),
12614            &mut writer,
12615        )?;
12616        let output = writer.get_content();
12617        assert!(output.contains(r#""known":"value""#));
12618        assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
12619        Ok(())
12620    }
12621
12622    #[test]
12623    fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
12624        let mut fragments = HashMap::new();
12625        fragments.insert(
12626            "index.html".to_string(),
12627            FragmentList {
12628                fragments: vec![
12629                    WebUIFragment::raw("<html><body>"),
12630                    WebUIFragment::component("app-shell"),
12631                    structural_fragment("body_end"),
12632                    WebUIFragment::raw("</body></html>"),
12633                ],
12634                contains_boundary: false,
12635            },
12636        );
12637        fragments.insert(
12638            "app-shell".to_string(),
12639            FragmentList {
12640                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12641                contains_boundary: false,
12642            },
12643        );
12644        let mut protocol = WebUIProtocol::new(fragments);
12645        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12646        let state = test_json!({
12647            "known": "value",
12648            "serverOnly": "must not be dropped",
12649        });
12650        let handler = WebUIHandler::with_plugin(|| {
12651            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12652        });
12653        let mut writer = TestWriter::new();
12654        handler.handle(
12655            &protocol,
12656            &state,
12657            &RenderOptions::new("index.html", "/"),
12658            &mut writer,
12659        )?;
12660        let output = writer.get_content();
12661        assert!(output.contains(r#""known":"value""#));
12662        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12663        Ok(())
12664    }
12665
12666    #[test]
12667    fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
12668        let mut fragments = HashMap::new();
12669        fragments.insert(
12670            "index.html".to_string(),
12671            FragmentList {
12672                fragments: vec![
12673                    WebUIFragment::raw("<html><body>"),
12674                    WebUIFragment::component("app-shell"),
12675                    structural_fragment("body_end"),
12676                    WebUIFragment::raw("</body></html>"),
12677                ],
12678                contains_boundary: false,
12679            },
12680        );
12681        fragments.insert(
12682            "app-shell".to_string(),
12683            FragmentList {
12684                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12685                contains_boundary: false,
12686            },
12687        );
12688        let mut protocol = WebUIProtocol::new(fragments);
12689        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12690        protocol.components.insert(
12691            "app-shell".to_string(),
12692            webui_protocol::ComponentData {
12693                hydration_mode: i32::MAX,
12694                ..Default::default()
12695            },
12696        );
12697        let state = test_json!({
12698            "known": "value",
12699            "serverOnly": "must not be dropped",
12700        });
12701        let handler = WebUIHandler::with_plugin(|| {
12702            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12703        });
12704        let mut writer = TestWriter::new();
12705        handler.handle(
12706            &protocol,
12707            &state,
12708            &RenderOptions::new("index.html", "/"),
12709            &mut writer,
12710        )?;
12711        let output = writer.get_content();
12712        assert!(output.contains(r#""known":"value""#));
12713        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12714        Ok(())
12715    }
12716
12717    #[test]
12718    fn legacy_navigation_keys_with_default_mode_remain_keyed() {
12719        let mut protocol = WebUIProtocol::new(HashMap::new());
12720        protocol.components.insert(
12721            "app-shell".to_string(),
12722            webui_protocol::ComponentData {
12723                navigation_keys: vec!["selected".to_string()],
12724                ..Default::default()
12725            },
12726        );
12727
12728        match collect_navigation_state(&protocol, ["app-shell"]) {
12729            StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
12730            StateSelection::Full
12731            | StateSelection::KeyIds(_)
12732            | StateSelection::FullExceptKeyIds(_) => {
12733                panic!("legacy navigation keys should remain owned and projected")
12734            }
12735        }
12736    }
12737
12738    #[test]
12739    fn legacy_navigation_without_projection_metadata_preserves_full_state() {
12740        let mut protocol = WebUIProtocol::new(HashMap::new());
12741        protocol.components.insert(
12742            "app-shell".to_string(),
12743            webui_protocol::ComponentData::default(),
12744        );
12745
12746        assert!(matches!(
12747            collect_navigation_state(&protocol, ["app-shell"]),
12748            StateSelection::Full
12749        ));
12750    }
12751
12752    #[test]
12753    fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
12754        let mut fragments = HashMap::new();
12755        fragments.insert(
12756            "index.html".to_string(),
12757            FragmentList {
12758                fragments: vec![
12759                    WebUIFragment::raw("<html><body>".to_string()),
12760                    structural_fragment("body_end"),
12761                    WebUIFragment::raw("</body></html>".to_string()),
12762                ],
12763                contains_boundary: false,
12764            },
12765        );
12766        let mut protocol = WebUIProtocol::new(fragments);
12767        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12768        let state = test_json!({
12769            "title": "Legacy state",
12770            "serverOnly": "preserved",
12771        });
12772        let handler = WebUIHandler::with_plugin(|| {
12773            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12774        });
12775
12776        let mut writer = TestWriter::new();
12777        handler.handle(
12778            &protocol,
12779            &state,
12780            &RenderOptions::new("index.html", "/"),
12781            &mut writer,
12782        )?;
12783        assert!(writer.get_content().contains(r#""state":{}"#));
12784        assert!(!writer.get_content().contains("Legacy state"));
12785        assert!(!writer.get_content().contains("preserved"));
12786        Ok(())
12787    }
12788
12789    #[test]
12790    fn scriptless_component_state_is_navigation_only() -> Result<()> {
12791        let mut fragments = HashMap::new();
12792        fragments.insert(
12793            "index.html".to_string(),
12794            FragmentList {
12795                fragments: vec![
12796                    WebUIFragment::raw("<html><body>"),
12797                    WebUIFragment::component("items-page"),
12798                    structural_fragment("body_end"),
12799                    WebUIFragment::raw("</body></html>"),
12800                ],
12801                contains_boundary: false,
12802            },
12803        );
12804        fragments.insert(
12805            "items-page".to_string(),
12806            FragmentList {
12807                fragments: vec![WebUIFragment::raw("<p>Items</p>")],
12808                contains_boundary: false,
12809            },
12810        );
12811        let mut protocol = WebUIProtocol::new(fragments);
12812        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12813        protocol.components.insert(
12814            "items-page".to_string(),
12815            webui_protocol::ComponentData {
12816                template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
12817                navigation_mode: Some(StateProjectionMode::Keys as i32),
12818                navigation_keys: vec!["items".into()],
12819                ..Default::default()
12820            },
12821        );
12822        let state = test_json!({
12823            "items": ["STATE_SENTINEL"],
12824            "serverOnly": "SECRET_SENTINEL",
12825        });
12826        let handler = WebUIHandler::with_plugin(|| {
12827            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12828        });
12829        let mut writer = TestWriter::new();
12830
12831        handler.handle(
12832            &protocol,
12833            &state,
12834            &RenderOptions::new("index.html", "/"),
12835            &mut writer,
12836        )?;
12837
12838        let output = writer.get_content();
12839        assert!(output.contains(r#""state":{}"#));
12840        assert!(!output.contains("STATE_SENTINEL"));
12841        assert!(!output.contains("SECRET_SENTINEL"));
12842        Ok(())
12843    }
12844
12845    #[test]
12846    fn write_selected_state_projects_and_escapes() {
12847        // `keep` is in the sorted key set and its value contains a `</` that
12848        // must be escaped; `drop` is absent and must be projected out.
12849        let state = test_json!({
12850            "drop": "secret",
12851            "keep": "</script><b>"
12852        });
12853        let keys = ["keep"];
12854        let selection = StateSelection::Keys(keys.to_vec());
12855        let mut sink = TestWriter::new();
12856        let mut scratch = Vec::new();
12857        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12858        assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
12859    }
12860
12861    #[test]
12862    fn write_selected_state_non_object_projection_emits_empty_object() {
12863        let state = test_json!("scalar state has nothing hydratable");
12864        let keys: [&str; 0] = [];
12865        let selection = StateSelection::Keys(keys.to_vec());
12866        let mut sink = TestWriter::new();
12867        let mut scratch = Vec::new();
12868        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12869        assert_eq!(sink.get_content(), "{}");
12870    }
12871
12872    #[test]
12873    fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
12874        let state = test_json!({
12875            "keptA": 1,
12876            "keptB": 2,
12877            "serverOnlyA": 3,
12878            "serverOnlyB": 4,
12879        });
12880        let keys = ["keptA", "keptA", "keptB", "missing"];
12881        let selection = StateSelection::Keys(keys.to_vec());
12882        let mut sink = TestWriter::new();
12883        let mut scratch = Vec::new();
12884        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12885        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12886    }
12887
12888    #[test]
12889    fn write_selected_state_map_first_matches_schema_first_output() {
12890        let state = test_json!({
12891            "keptA": 1,
12892            "keptB": 2,
12893        });
12894        let keys = ["keptA", "keptB", "missingA", "missingB"];
12895        let selection = StateSelection::Keys(keys.to_vec());
12896        let mut sink = TestWriter::new();
12897        let mut scratch = Vec::new();
12898        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12899        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12900    }
12901
12902    #[test]
12903    fn write_selected_state_full_preserves_and_escapes_state() {
12904        let state = test_json!({
12905            "serverOnly": "</script><b>",
12906            "value": 42,
12907        });
12908        let mut sink = TestWriter::new();
12909        let mut scratch = Vec::new();
12910        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12911        assert_eq!(
12912            sink.get_content(),
12913            r#"{"serverOnly":"<\/script><b>","value":42}"#
12914        );
12915    }
12916
12917    #[test]
12918    fn script_safe_json_str_escapes_only_end_tag_openers() -> Result<()> {
12919        let cases = [
12920            ("", ""),
12921            ("</", "<\\/"),
12922            ("</a</b", "<\\/a<\\/b"),
12923            ("<<//", "<<\\//"),
12924            ("a<b>c<", "a<b>c<"),
12925            ("x</", "x<\\/"),
12926            ("a<b>c</d>e</f", "a<b>c<\\/d>e<\\/f"),
12927            ("<img src=\"/a.png\"><br/>", "<img src=\"/a.png\"><br/>"),
12928        ];
12929        for (json, expected) in cases {
12930            let mut sink = TestWriter::new();
12931            write_script_safe_json_str(&mut sink, json)?;
12932            assert_eq!(sink.get_content(), expected, "input {json:?}");
12933        }
12934        Ok(())
12935    }
12936
12937    #[test]
12938    fn script_safe_json_str_finds_end_tag_openers_at_every_offset() -> Result<()> {
12939        // The `</` search switches between short-haystack and vectorized
12940        // paths, so move openers and `<`/`/` decoys across several blocks.
12941        let mut json = String::with_capacity(512);
12942        for gap in 0..130 {
12943            json.clear();
12944            json.push_str("{\"h\":\"<img src=\\\"/a.png\\\">");
12945            json.push_str(&"x".repeat(gap));
12946            json.push_str("</p><br/>");
12947            json.push_str(&"y".repeat(gap % 37));
12948            json.push_str("</");
12949            let mut sink = TestWriter::new();
12950            write_script_safe_json_str(&mut sink, &json)?;
12951            assert_eq!(sink.get_content(), json.replace("</", "<\\/"), "gap {gap}");
12952        }
12953        Ok(())
12954    }
12955
12956    #[test]
12957    fn component_asset_style_manifest_is_serialized_once_and_script_safe() {
12958        let preloads = vec![ComponentAssetStylePreload {
12959            root: "lazy-panel".to_string(),
12960            style_hrefs: vec!["/assets/</script><script>alert(1)</script>.css".to_string()],
12961        }];
12962
12963        let manifest = serialize_component_asset_style_manifest(&preloads).unwrap();
12964
12965        assert_eq!(
12966            manifest,
12967            r#"{"lazy-panel":["/assets/<\/script><script>alert(1)<\/script>.css"]}"#
12968        );
12969    }
12970
12971    #[test]
12972    fn json_scratch_capacity_reused_across_checkpoint_payloads() {
12973        // Simulates several checkpoint bootstraps serialized in one render: a
12974        // single request-local buffer must grow once for the largest payload and
12975        // then be reused for subsequent payloads without reallocating. This is
12976        // the deterministic seam behind the streaming path's allocation gate —
12977        // `write_script_safe_json` is `pub(crate)`, so no public API is exposed.
12978        let mut sink = TestWriter::new();
12979        let mut scratch: Vec<u8> = Vec::new();
12980        // No allocation until serialization actually needs it.
12981        assert_eq!(scratch.capacity(), 0);
12982
12983        // The largest payload first establishes the high-water capacity.
12984        let mut large = serde_json::Map::new();
12985        for index in 0..64 {
12986            large.insert(
12987                format!("component_{index:04}"),
12988                Value::String("x".repeat(48)),
12989            );
12990        }
12991        let large = Value::Object(large);
12992        write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
12993        let high_water = scratch.capacity();
12994        assert!(high_water > 0);
12995
12996        // Subsequent smaller checkpoint payloads reuse the same buffer: capacity
12997        // never grows (no per-checkpoint allocation) and never shrinks.
12998        for seq in 0..8 {
12999            let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
13000            write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
13001            assert_eq!(
13002                scratch.capacity(),
13003                high_water,
13004                "scratch reallocated on checkpoint {seq}"
13005            );
13006        }
13007    }
13008
13009    #[test]
13010    fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
13011        let mut fragments = HashMap::new();
13012        fragments.insert(
13013            "index.html".to_string(),
13014            FragmentList {
13015                fragments: vec![
13016                    WebUIFragment::raw("<html><body>"),
13017                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
13018                        path: "/".to_string(),
13019                        fragment_id: "home-page".to_string(),
13020                        exact: true,
13021                        ..Default::default()
13022                    }),
13023                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
13024                        path: "/admin".to_string(),
13025                        fragment_id: "admin-page".to_string(),
13026                        exact: true,
13027                        ..Default::default()
13028                    }),
13029                    structural_fragment("body_end"),
13030                    WebUIFragment::raw("</body></html>"),
13031                ],
13032
13033                contains_boundary: false,
13034            },
13035        );
13036        fragments.insert(
13037            "home-page".to_string(),
13038            FragmentList {
13039                fragments: vec![WebUIFragment::raw("<p>Home</p>")],
13040                contains_boundary: false,
13041            },
13042        );
13043        fragments.insert(
13044            "admin-page".to_string(),
13045            FragmentList {
13046                fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
13047                contains_boundary: false,
13048            },
13049        );
13050
13051        let mut protocol = WebUIProtocol::new(fragments);
13052        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13053        protocol.components.insert(
13054            "home-page".to_string(),
13055            webui_protocol::ComponentData {
13056                template_json: "{}".to_string(),
13057                hydration_mode: StateProjectionMode::Keys as i32,
13058                hydration_keys: vec!["homeTitle".to_string()],
13059                ..Default::default()
13060            },
13061        );
13062        protocol.components.insert(
13063            "admin-page".to_string(),
13064            webui_protocol::ComponentData {
13065                template_json: "{}".to_string(),
13066                hydration_mode: StateProjectionMode::Keys as i32,
13067                hydration_keys: vec!["adminToken".to_string()],
13068                ..Default::default()
13069            },
13070        );
13071        let state = test_json!({
13072            "homeTitle": "Welcome",
13073            "adminToken": "TOP_SECRET_SENTINEL",
13074        });
13075        let handler = WebUIHandler::with_plugin(|| {
13076            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13077        });
13078        let mut writer = TestWriter::new();
13079        handler.handle(
13080            &protocol,
13081            &state,
13082            &RenderOptions::new("index.html", "/"),
13083            &mut writer,
13084        )?;
13085        let output = writer.get_content();
13086        assert!(output.contains(r#""homeTitle":"Welcome""#));
13087        assert!(!output.contains("TOP_SECRET_SENTINEL"));
13088        assert!(!output.contains(r#""adminToken""#));
13089        Ok(())
13090    }
13091
13092    #[test]
13093    fn test_component_attr_name_aria() {
13094        // component_attr_name correctly maps ARIA attributes via the shared table
13095        assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
13096        assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
13097        assert_eq!(
13098            component_attr_name("aria-activedescendant"),
13099            "ariaActiveDescendant"
13100        );
13101        assert_eq!(component_attr_name("aria-label"), "ariaLabel");
13102        assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
13103    }
13104
13105    #[test]
13106    fn test_component_attr_name_html_global() {
13107        assert_eq!(component_attr_name("readonly"), "readOnly");
13108        assert_eq!(component_attr_name("tabindex"), "tabIndex");
13109        assert_eq!(component_attr_name("accesskey"), "accessKey");
13110        assert_eq!(component_attr_name("contenteditable"), "contentEditable");
13111        assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
13112        assert_eq!(component_attr_name("inputmode"), "inputMode");
13113        assert_eq!(component_attr_name("maxlength"), "maxLength");
13114        assert_eq!(component_attr_name("minlength"), "minLength");
13115        assert_eq!(component_attr_name("novalidate"), "noValidate");
13116        assert_eq!(component_attr_name("formaction"), "formAction");
13117        assert_eq!(component_attr_name("ismap"), "isMap");
13118        assert_eq!(component_attr_name("usemap"), "useMap");
13119    }
13120
13121    #[test]
13122    fn test_component_attr_name_strips_colon() {
13123        assert_eq!(component_attr_name(":readonly"), "readOnly");
13124        assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
13125        assert_eq!(component_attr_name(":data-title"), "dataTitle");
13126    }
13127
13128    #[test]
13129    fn test_component_attr_name_regular() {
13130        assert_eq!(component_attr_name("data-title"), "dataTitle");
13131        assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
13132        assert_eq!(component_attr_name("simple"), "simple");
13133    }
13134
13135    // ── allowed_query SSR emission tests ─────────────────────────────
13136
13137    fn make_query_route_protocol() -> WebUIProtocol {
13138        use webui_protocol::WebUiFragmentRoute;
13139
13140        let mut fragments = HashMap::new();
13141
13142        fragments.insert(
13143            "index.html".to_string(),
13144            FragmentList {
13145                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
13146                    path: "/".into(),
13147                    fragment_id: "app-shell".into(),
13148                    exact: false,
13149                    children: vec![
13150                        WebUiFragmentRoute {
13151                            path: "compose".into(),
13152                            fragment_id: "compose-page".into(),
13153                            exact: true,
13154                            allowed_query: "action,to,subject".into(),
13155                            keep_alive: false,
13156                            ..Default::default()
13157                        },
13158                        WebUiFragmentRoute {
13159                            path: "settings".into(),
13160                            fragment_id: "settings-page".into(),
13161                            exact: true,
13162                            keep_alive: false,
13163                            ..Default::default()
13164                        },
13165                    ],
13166                    keep_alive: false,
13167                    ..Default::default()
13168                })],
13169
13170                contains_boundary: false,
13171            },
13172        );
13173
13174        fragments.insert(
13175            "app-shell".to_string(),
13176            FragmentList {
13177                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
13178                contains_boundary: false,
13179            },
13180        );
13181        fragments.insert(
13182            "compose-page".to_string(),
13183            FragmentList {
13184                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
13185                contains_boundary: false,
13186            },
13187        );
13188        fragments.insert(
13189            "settings-page".to_string(),
13190            FragmentList {
13191                fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
13192                contains_boundary: false,
13193            },
13194        );
13195
13196        WebUIProtocol::new(fragments)
13197    }
13198
13199    #[test]
13200    fn test_matched_route_omits_query_attr_from_dom() {
13201        let protocol = make_query_route_protocol();
13202        let state = test_json!({});
13203        let handler = WebUIHandler::new();
13204        let mut writer = TestWriter::new();
13205
13206        handler
13207            .handle(
13208                &protocol,
13209                &state,
13210                &RenderOptions::new("index.html", "/compose"),
13211                &mut writer,
13212            )
13213            .expect("render failed");
13214
13215        let html = writer.get_content();
13216        // query attr is no longer in DOM — it's in the SSR chain JSON instead
13217        assert!(
13218            !html.contains(r#"query="action,to,subject""#),
13219            "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
13220        );
13221    }
13222
13223    #[test]
13224    fn test_nonmatched_route_omits_query_attr_from_dom() {
13225        let protocol = make_query_route_protocol();
13226        let state = test_json!({});
13227        let handler = WebUIHandler::new();
13228        let mut writer = TestWriter::new();
13229
13230        handler
13231            .handle(
13232                &protocol,
13233                &state,
13234                &RenderOptions::new("index.html", "/settings"),
13235                &mut writer,
13236            )
13237            .expect("render failed");
13238
13239        let html = writer.get_content();
13240        // query attr should not be on hidden siblings either
13241        assert!(
13242            !html.contains(r#"query="#),
13243            "hidden route should not have query attr: {html}"
13244        );
13245    }
13246
13247    #[test]
13248    fn test_route_without_query_has_no_query_attr() {
13249        let protocol = make_query_route_protocol();
13250        let state = test_json!({});
13251        let handler = WebUIHandler::new();
13252        let mut writer = TestWriter::new();
13253
13254        handler
13255            .handle(
13256                &protocol,
13257                &state,
13258                &RenderOptions::new("index.html", "/settings"),
13259                &mut writer,
13260            )
13261            .expect("render failed");
13262
13263        let html = writer.get_content();
13264        // Find the settings route element and verify it has no query attr
13265        let settings_idx = html
13266            .find(r#"component="settings-page""#)
13267            .expect("settings route should exist");
13268        let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
13269        assert!(
13270            !settings_tag.contains("query="),
13271            "route without allowed_query should not emit query attr: {settings_tag}"
13272        );
13273    }
13274
13275    // ── Per-render head_inject / body_inject (replaces the byte-scanner
13276    //    InjectingStreamingWriter approach with structural signal-based
13277    //    injection) ───────────────────────────────────────────────────
13278
13279    fn build_head_body_protocol() -> WebUIProtocol {
13280        let mut fragments = HashMap::new();
13281        fragments.insert(
13282            "index.html".to_string(),
13283            FragmentList {
13284                fragments: vec![
13285                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13286                    structural_fragment("head_end"),
13287                    WebUIFragment::raw("</head><body>hello".to_string()),
13288                    structural_fragment("body_end"),
13289                    WebUIFragment::raw("</body></html>".to_string()),
13290                ],
13291                contains_boundary: false,
13292            },
13293        );
13294        WebUIProtocol::new(fragments)
13295    }
13296
13297    #[test]
13298    fn head_inject_emits_at_head_end_boundary() {
13299        let protocol = build_head_body_protocol();
13300        let state = test_json!({});
13301        let mut writer = TestWriter::new();
13302        let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
13303        handle(&protocol, &state, &opts, &mut writer).unwrap();
13304        let html = writer.get_content();
13305        // The inject must appear immediately before `</head>`.
13306        let inject_idx = html
13307            .find("<link rel=preload>")
13308            .expect("inject HTML missing");
13309        let head_close_idx = html.find("</head>").expect("</head> missing");
13310        assert!(
13311            inject_idx < head_close_idx,
13312            "head_inject must appear before </head>: {html}"
13313        );
13314        // No duplicate.
13315        assert_eq!(html.matches("<link rel=preload>").count(), 1);
13316    }
13317
13318    #[test]
13319    fn component_render_policy_css_emits_once_in_head_with_nonce() {
13320        let mut protocol = build_head_body_protocol();
13321        protocol.component_render_css = concat!(
13322            r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
13323            "contain-intrinsic-block-size:auto 18rem;}"
13324        )
13325        .to_string();
13326        let state = test_json!({});
13327        let mut writer = TestWriter::new();
13328        let opts = RenderOptions::new("index.html", "/").with_nonce("test-nonce");
13329        handle(&protocol, &state, &opts, &mut writer).unwrap();
13330        let html = writer.get_content();
13331
13332        let style = concat!(
13333            r#"<style data-webui-render-policy nonce="test-nonce">"#,
13334            r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
13335            "contain-intrinsic-block-size:auto 18rem;}</style>"
13336        );
13337        let style_index = html.find(style).expect("render policy style missing");
13338        let head_close = html.find("</head>").expect("</head> missing");
13339        assert!(style_index < head_close);
13340        assert_eq!(html.matches("data-webui-render-policy").count(), 1);
13341    }
13342
13343    #[test]
13344    fn body_inject_emits_at_body_end_boundary() {
13345        let protocol = build_head_body_protocol();
13346        let state = test_json!({});
13347        let mut writer = TestWriter::new();
13348        let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
13349        handle(&protocol, &state, &opts, &mut writer).unwrap();
13350        let html = writer.get_content();
13351        let inject_idx = html
13352            .find("<script>lr</script>")
13353            .expect("inject HTML missing");
13354        let body_close_idx = html.find("</body>").expect("</body> missing");
13355        assert!(
13356            inject_idx < body_close_idx,
13357            "body_inject must appear before </body>: {html}"
13358        );
13359        assert_eq!(html.matches("<script>lr</script>").count(), 1);
13360    }
13361
13362    // ── Reserved `$webui` state inject namespace ──────────────────────
13363
13364    /// Protocol carrying all three structural boundaries, so `body_start`
13365    /// placement can be asserted alongside `head_end` / `body_end`.
13366    fn build_all_boundaries_protocol() -> WebUIProtocol {
13367        let mut fragments = HashMap::new();
13368        fragments.insert(
13369            "index.html".to_string(),
13370            FragmentList {
13371                fragments: vec![
13372                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13373                    structural_fragment("head_end"),
13374                    WebUIFragment::raw("</head><body>".to_string()),
13375                    structural_fragment("body_start"),
13376                    WebUIFragment::raw("hello".to_string()),
13377                    structural_fragment("body_end"),
13378                    WebUIFragment::raw("</body></html>".to_string()),
13379                ],
13380                contains_boundary: false,
13381            },
13382        );
13383        WebUIProtocol::new(fragments)
13384    }
13385
13386    fn state_inject_options<'a>() -> RenderOptions<'a> {
13387        RenderOptions::new("index.html", "/")
13388    }
13389
13390    fn render_with(protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>) -> String {
13391        let mut writer = TestWriter::new();
13392        handle(protocol, state, options, &mut writer).unwrap();
13393        writer.get_content().to_string()
13394    }
13395
13396    #[test]
13397    fn state_inject_emits_at_every_structural_boundary() {
13398        let protocol = build_all_boundaries_protocol();
13399        let state = test_json!({
13400            "$webui": {
13401                "headEnd": "<meta name=he>",
13402                "bodyStart": "<span id=bs></span>",
13403                "bodyEnd": "<script>be</script>",
13404            }
13405        });
13406        let html = render_with(&protocol, &state, &state_inject_options());
13407
13408        let head_end = html.find("<meta name=he>").expect("headEnd missing");
13409        let head_close = html.find("</head>").expect("</head> missing");
13410        let body_open = html.find("<body>").expect("<body> missing");
13411        let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
13412        let hello = html.find("hello").expect("body content missing");
13413        let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
13414        let body_close = html.find("</body>").expect("</body> missing");
13415
13416        assert!(
13417            head_end < head_close,
13418            "headEnd must precede </head>: {html}"
13419        );
13420        assert!(
13421            body_open < body_start && body_start < hello,
13422            "bodyStart must sit immediately after <body>: {html}"
13423        );
13424        assert!(
13425            hello < body_end && body_end < body_close,
13426            "bodyEnd must precede </body>: {html}"
13427        );
13428
13429        for needle in [
13430            "<meta name=he>",
13431            "<span id=bs></span>",
13432            "<script>be</script>",
13433        ] {
13434            assert_eq!(html.matches(needle).count(), 1, "duplicated {needle}");
13435        }
13436    }
13437
13438    #[test]
13439    fn state_inject_follows_render_options_inject() {
13440        let protocol = build_all_boundaries_protocol();
13441        let state = test_json!({
13442            "$webui": { "headEnd": "<!--state-he-->", "bodyEnd": "<!--state-be-->" }
13443        });
13444        let options = RenderOptions::new("index.html", "/")
13445            .with_head_inject("<!--opt-he-->")
13446            .with_body_inject("<!--opt-be-->");
13447        let html = render_with(&protocol, &state, &options);
13448
13449        let opt_he = html.find("<!--opt-he-->").expect("option headEnd missing");
13450        let state_he = html.find("<!--state-he-->").expect("state headEnd missing");
13451        let opt_be = html.find("<!--opt-be-->").expect("option bodyEnd missing");
13452        let state_be = html.find("<!--state-be-->").expect("state bodyEnd missing");
13453
13454        assert!(
13455            opt_he < state_he,
13456            "RenderOptions head_inject must precede the state-supplied value: {html}"
13457        );
13458        assert!(
13459            opt_be < state_be,
13460            "RenderOptions body_inject must precede the state-supplied value: {html}"
13461        );
13462    }
13463
13464    #[test]
13465    fn malformed_state_inject_values_are_inert() {
13466        let protocol = build_all_boundaries_protocol();
13467        // Absent key, wrong container type, and per-member null / empty /
13468        // non-string values must all render without output and without error.
13469        for state in [
13470            test_json!({}),
13471            test_json!({ "$webui": "not-an-object" }),
13472            test_json!({ "$webui": [] }),
13473            test_json!({ "$webui": null }),
13474            test_json!({ "$webui": { "headEnd": null, "bodyStart": "", "bodyEnd": 42 } }),
13475            test_json!({ "$webui": { "unknownMember": "<b>x</b>" } }),
13476        ] {
13477            let html = render_with(&protocol, &state, &state_inject_options());
13478            assert!(
13479                html.contains("<html><head><title>x</title></head><body>hello</body></html>"),
13480                "malformed reserved state must render the document unchanged: {html}"
13481            );
13482            assert!(!html.contains("<b>x</b>"), "unknown member leaked: {html}");
13483            assert!(!html.contains("42"), "non-string member leaked: {html}");
13484        }
13485    }
13486
13487    #[test]
13488    fn state_inject_never_reaches_the_hydration_payload() {
13489        let protocol = build_all_boundaries_protocol();
13490        let state = test_json!({
13491            "visible": "keep",
13492            "$webui": { "bodyEnd": "<script>secret</script>" }
13493        });
13494        let handler = WebUIHandler::with_plugin(|| {
13495            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13496        });
13497        let runtime = Protocol::new(protocol.clone());
13498        let mut writer = TestWriter::new();
13499        handler
13500            .render(&runtime, &state, &state_inject_options(), &mut writer)
13501            .unwrap();
13502        let html = writer.get_content();
13503
13504        let data_start = html
13505            .find(r#"<script type="application/json" id="webui-data""#)
13506            .expect("hydration block missing");
13507        let data_end = html[data_start..]
13508            .find("</script>")
13509            .map(|offset| data_start + offset)
13510            .expect("hydration block never closes");
13511        let payload = &html[data_start..data_end];
13512        assert!(
13513            !payload.contains("$webui"),
13514            "reserved key must be stripped from the hydration payload: {payload}"
13515        );
13516        assert!(
13517            payload.contains("visible"),
13518            "ordinary state must survive the filter: {payload}"
13519        );
13520        // Per the documented precedence the injected HTML is emitted after
13521        // the built-in hydration block, and still before `</body>`.
13522        let inject = html
13523            .find("<script>secret</script>")
13524            .expect("inject missing");
13525        let body_close = html.find("</body>").expect("</body> missing");
13526        assert!(
13527            data_end < inject && inject < body_close,
13528            "state bodyEnd must follow the hydration block and precede </body>: {html}"
13529        );
13530    }
13531
13532    #[test]
13533    fn projected_hydration_strips_reserved_state_inject_key() {
13534        let mut fragments = HashMap::new();
13535        fragments.insert(
13536            "index.html".to_string(),
13537            FragmentList {
13538                fragments: vec![
13539                    WebUIFragment::raw("<html><body>"),
13540                    WebUIFragment::component("app-shell"),
13541                    structural_fragment("body_end"),
13542                    WebUIFragment::raw("</body></html>"),
13543                ],
13544                contains_boundary: false,
13545            },
13546        );
13547        fragments.insert(
13548            "app-shell".to_string(),
13549            FragmentList {
13550                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
13551                contains_boundary: false,
13552            },
13553        );
13554        let mut document = WebUIProtocol::new(fragments);
13555        document.initial_state_strategy = InitialStateStrategy::Components as i32;
13556        document.components.insert(
13557            "app-shell".to_string(),
13558            webui_protocol::ComponentData {
13559                hydration_mode: StateProjectionMode::Keys as i32,
13560                hydration_keys: vec![STATE_INJECT_KEY.to_string(), "visible".to_string()],
13561                ..Default::default()
13562            },
13563        );
13564        let state = test_json!({
13565            "$webui": { "bodyEnd": "<script>secret</script>" },
13566            "serverOnly": "drop",
13567            "visible": "keep",
13568        });
13569        let handler = WebUIHandler::with_plugin(|| {
13570            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13571        });
13572        let mut writer = TestWriter::new();
13573
13574        handler
13575            .render(
13576                &Protocol::new(document),
13577                &state,
13578                &RenderOptions::new("index.html", "/"),
13579                &mut writer,
13580            )
13581            .unwrap();
13582        let html = writer.get_content();
13583        let data_start = html
13584            .find(r#"<script type="application/json" id="webui-data""#)
13585            .expect("hydration block missing");
13586        let data_end = html[data_start..]
13587            .find("</script>")
13588            .map(|offset| data_start + offset)
13589            .expect("hydration block never closes");
13590        let payload = &html[data_start..data_end];
13591
13592        assert!(
13593            !payload.contains(STATE_INJECT_KEY),
13594            "reserved key leaked: {payload}"
13595        );
13596        assert!(payload.contains(r#""visible":"keep""#), "{payload}");
13597        assert!(!payload.contains("serverOnly"), "{payload}");
13598        assert!(html.contains("<script>secret</script>"), "{html}");
13599    }
13600
13601    #[test]
13602    fn write_selected_state_strips_reserved_key_from_full_state() {
13603        let state = test_json!({ "a": 1, "$webui": { "bodyEnd": "<b>x</b>" }, "z": 2 });
13604        let mut sink = TestWriter::new();
13605        let mut scratch = Vec::new();
13606        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13607        let json = sink.get_content();
13608        assert!(!json.contains("$webui"), "reserved key leaked: {json}");
13609        assert!(
13610            json.contains("\"a\":1") && json.contains("\"z\":2"),
13611            "{json}"
13612        );
13613    }
13614
13615    #[test]
13616    fn write_selected_state_strips_reserved_key_from_key_id_projection() {
13617        // The streaming record projects through interned key IDs, so the
13618        // reserved inject key must be filtered on that path too.
13619        let mut protocol = WebUIProtocol::new(HashMap::new());
13620        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13621        protocol.fragments.insert(
13622            "keep-card".to_string(),
13623            webui_protocol::FragmentList::default(),
13624        );
13625        protocol.components.insert(
13626            "keep-card".to_string(),
13627            webui_protocol::ComponentData {
13628                hydration_mode: StateProjectionMode::Keys as i32,
13629                hydration_keys: vec![STATE_INJECT_KEY.to_string(), "keep".to_string()],
13630                ..Default::default()
13631            },
13632        );
13633        let protocol = Protocol::new(protocol);
13634        let index = protocol.component_reachability();
13635        let component = protocol.component_index()["keep-card"];
13636        let mut ids = Vec::new();
13637        assert!(!collect_hydration_key_ids_into(
13638            protocol.protocol(),
13639            index,
13640            [component],
13641            &mut ids
13642        ));
13643
13644        let state = test_json!({
13645            "$webui": { "bodyEnd": "<b>x</b>" },
13646            "keep": 1,
13647        });
13648        let mut sink = TestWriter::new();
13649        let mut scratch = Vec::new();
13650
13651        write_selected_state(
13652            &mut sink,
13653            &mut scratch,
13654            &state,
13655            &StateSelection::KeyIds(HydrationKeySelection { ids: &ids, index }),
13656        )
13657        .unwrap();
13658
13659        assert_eq!(sink.get_content(), r#"{"keep":1}"#);
13660    }
13661
13662    #[test]
13663    fn write_selected_state_full_is_unchanged_without_reserved_key() {
13664        let state = test_json!({ "a": 1, "z": 2 });
13665        let mut sink = TestWriter::new();
13666        let mut scratch = Vec::new();
13667        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13668        assert_eq!(sink.get_content(), r#"{"a":1,"z":2}"#);
13669    }
13670
13671    #[test]
13672    fn body_start_hook_dedupes_on_malformed_protocol() {
13673        let mut fragments = HashMap::new();
13674        fragments.insert(
13675            "index.html".to_string(),
13676            FragmentList {
13677                fragments: vec![
13678                    WebUIFragment::raw("<html><head></head><body>".to_string()),
13679                    structural_fragment("body_start"),
13680                    structural_fragment("body_start"),
13681                    WebUIFragment::raw("</body></html>".to_string()),
13682                ],
13683                contains_boundary: false,
13684            },
13685        );
13686        let protocol = WebUIProtocol::new(fragments);
13687        let state = test_json!({ "$webui": { "bodyStart": "<i>once</i>" } });
13688        let html = render_with(&protocol, &state, &state_inject_options());
13689        assert_eq!(
13690            html.matches("<i>once</i>").count(),
13691            1,
13692            "duplicate body_start signals must not duplicate the inject: {html}"
13693        );
13694    }
13695
13696    #[test]
13697    fn injects_are_no_op_when_unset() {
13698        let protocol = build_head_body_protocol();
13699        let state = test_json!({});
13700        let mut writer = TestWriter::new();
13701        handle(
13702            &protocol,
13703            &state,
13704            &RenderOptions::new("index.html", "/"),
13705            &mut writer,
13706        )
13707        .unwrap();
13708        let html = writer.get_content();
13709        assert!(!html.contains("<link rel=preload>"));
13710        assert!(!html.contains("<script>lr</script>"));
13711    }
13712
13713    #[test]
13714    fn empty_inject_string_treated_as_unset() {
13715        let protocol = build_head_body_protocol();
13716        let state = test_json!({});
13717        let mut writer = TestWriter::new();
13718        let opts = RenderOptions::new("index.html", "/")
13719            .with_head_inject("")
13720            .with_body_inject("");
13721        handle(&protocol, &state, &opts, &mut writer).unwrap();
13722        // No injection happens — empty strings are normalised to None
13723        // by the builder, so the output is identical to the no-options case.
13724        let html = writer.get_content();
13725        assert!(html.contains("</head>"));
13726        assert!(html.contains("</body>"));
13727    }
13728
13729    #[test]
13730    fn inject_html_is_passed_through_verbatim() {
13731        // The handler does NOT escape the inject string — hosts pass
13732        // raw HTML they trust. This test pins that contract: a `<` in
13733        // the inject is emitted as-is, not encoded as `&lt;`.
13734        let protocol = build_head_body_protocol();
13735        let state = test_json!({});
13736        let mut writer = TestWriter::new();
13737        let opts =
13738            RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
13739        handle(&protocol, &state, &opts, &mut writer).unwrap();
13740        assert!(writer.get_content().contains("<script>var x=1;</script>"));
13741    }
13742
13743    /// Both injects fire and appear at the correct structural
13744    /// positions. Critically, this is robust against `</head>` /
13745    /// `</body>` literals appearing elsewhere in the document — the
13746    /// signal-based emitter cannot mis-fire on byte patterns inside
13747    /// HTML comments, `<iframe srcdoc>`, or inline scripts (which the
13748    /// previous byte-scanner could).
13749    #[test]
13750    fn injects_robust_against_marker_literals_in_content() {
13751        let mut fragments = HashMap::new();
13752        // The body intentionally contains `</body>` and `</head>`
13753        // literals before the actual structural close — these came
13754        // from a (hypothetical) iframe srcdoc or comment.
13755        fragments.insert(
13756            "index.html".to_string(),
13757            FragmentList {
13758                fragments: vec![
13759                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13760                    structural_fragment("head_end"),
13761                    WebUIFragment::raw(
13762                        "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
13763                    ),
13764                    structural_fragment("body_end"),
13765                    WebUIFragment::raw("</body></html>".to_string()),
13766                ],
13767                contains_boundary: false,
13768            },
13769        );
13770        let protocol = WebUIProtocol::new(fragments);
13771        let state = test_json!({});
13772        let mut writer = TestWriter::new();
13773        let opts = RenderOptions::new("index.html", "/")
13774            .with_head_inject("<HEAD-INJ>")
13775            .with_body_inject("<BODY-INJ>");
13776        handle(&protocol, &state, &opts, &mut writer).unwrap();
13777        let html = writer.get_content();
13778        // The head inject sits between `<title>x</title>` and the
13779        // first `</head>` — the structural one, not the comment one.
13780        let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
13781        let head_close_idx = html.find("</head>").expect("</head> missing");
13782        assert!(head_inj_idx < head_close_idx);
13783        // The body inject sits before the structural `</body>` — NOT
13784        // before the `</body>` literal in the comment (which would
13785        // require the inject to appear inside `<p>hi</p>` somewhere).
13786        let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
13787        // Find the LAST `</body>` (the structural one).
13788        let body_close_idx = html.rfind("</body>").expect("</body> missing");
13789        assert!(
13790            body_inj_idx < body_close_idx,
13791            "body_inject must precede the structural </body>: {html}"
13792        );
13793        // And the comment is preserved verbatim.
13794        assert!(html.contains("<!-- </body> </head> -->"));
13795    }
13796
13797    /// Coverage-14: both `head_inject` AND `body_inject` set in the
13798    /// same render. Each fires at the correct structural boundary and
13799    /// neither leaks into the other's region.
13800    #[test]
13801    fn both_injects_fire_at_correct_boundaries() {
13802        let protocol = build_head_body_protocol();
13803        let state = test_json!({});
13804        let mut writer = TestWriter::new();
13805        let opts = RenderOptions::new("index.html", "/")
13806            .with_head_inject("<META-HEAD>")
13807            .with_body_inject("<SCRIPT-BODY>");
13808        handle(&protocol, &state, &opts, &mut writer).unwrap();
13809        let html = writer.get_content();
13810        let head_idx = html.find("<META-HEAD>").expect("head inject missing");
13811        let head_close = html.find("</head>").expect("</head> missing");
13812        let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
13813        let body_close = html.find("</body>").expect("</body> missing");
13814        assert!(head_idx < head_close, "head_inject before </head>");
13815        assert!(head_close < body_idx, "body_inject after </head>");
13816        assert!(body_idx < body_close, "body_inject before </body>");
13817        assert_eq!(html.matches("<META-HEAD>").count(), 1);
13818        assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
13819    }
13820
13821    /// Coverage-15 / Bug-3 (security defense): a malformed protocol
13822    /// emitting `head_end` and `body_end` more than once must NOT
13823    /// duplicate the host inject HTML. Without the dedup guard,
13824    /// double-emission would amplify Security-2 (a 1 MiB inject ×
13825    /// 1000 duplicate signals = 1 GiB output).
13826    #[test]
13827    fn injects_dedupe_against_duplicate_signals() {
13828        let mut fragments = HashMap::new();
13829        fragments.insert(
13830            "index.html".to_string(),
13831            FragmentList {
13832                fragments: vec![
13833                    WebUIFragment::raw("<html><head>".to_string()),
13834                    structural_fragment("head_end"),
13835                    structural_fragment("head_end"), // duplicate
13836                    structural_fragment("head_end"), // triplicate
13837                    WebUIFragment::raw("</head><body>".to_string()),
13838                    structural_fragment("body_end"),
13839                    structural_fragment("body_end"), // duplicate
13840                    WebUIFragment::raw("</body></html>".to_string()),
13841                ],
13842                contains_boundary: false,
13843            },
13844        );
13845        let protocol = WebUIProtocol::new(fragments);
13846        let state = test_json!({});
13847        let mut writer = TestWriter::new();
13848        let opts = RenderOptions::new("index.html", "/")
13849            .with_head_inject("<HINJ>")
13850            .with_body_inject("<BINJ>");
13851        handle(&protocol, &state, &opts, &mut writer).unwrap();
13852        let html = writer.get_content();
13853        assert_eq!(
13854            html.matches("<HINJ>").count(),
13855            1,
13856            "head_inject must emit exactly once even with duplicate head_end signals"
13857        );
13858        assert_eq!(
13859            html.matches("<BINJ>").count(),
13860            1,
13861            "body_inject must emit exactly once even with duplicate body_end signals"
13862        );
13863    }
13864
13865    /// Coverage-15: a Shadow-DOM / component-only protocol that has NO
13866    /// `<head>` / `<body>` tags must NOT emit the inject (the signals
13867    /// never fire). Verifies the injects are no-ops, not panics.
13868    #[test]
13869    fn injects_no_op_when_no_head_or_body_signals() {
13870        let mut fragments = HashMap::new();
13871        fragments.insert(
13872            "index.html".to_string(),
13873            FragmentList {
13874                fragments: vec![WebUIFragment::raw(
13875                    "<my-component>hi</my-component>".to_string(),
13876                )],
13877                contains_boundary: false,
13878            },
13879        );
13880        let protocol = WebUIProtocol::new(fragments);
13881        let state = test_json!({});
13882        let mut writer = TestWriter::new();
13883        let opts = RenderOptions::new("index.html", "/")
13884            .with_head_inject("<HINJ>")
13885            .with_body_inject("<BINJ>");
13886        handle(&protocol, &state, &opts, &mut writer).unwrap();
13887        let html = writer.get_content();
13888        assert!(!html.contains("<HINJ>"), "head_inject must not appear");
13889        assert!(!html.contains("<BINJ>"), "body_inject must not appear");
13890        assert!(html.contains("<my-component>"));
13891    }
13892
13893    /// Coverage-19: the handler's `&self` is shared across threads.
13894    /// Two concurrent renders with different inject values must NOT
13895    /// cross-contaminate (each thread sees only its own inject).
13896    /// Per-render mutable state lives on the `WebUIProcessContext`,
13897    /// which is stack-allocated per call.
13898    #[test]
13899    fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
13900        let protocol = std::sync::Arc::new(build_head_body_protocol());
13901        let state = std::sync::Arc::new(test_json!({}));
13902        let handler = std::sync::Arc::new(WebUIHandler::new());
13903
13904        const N_THREADS: usize = 16;
13905        let mut handles = Vec::with_capacity(N_THREADS);
13906        for tid in 0..N_THREADS {
13907            let h = std::sync::Arc::clone(&handler);
13908            let p = std::sync::Arc::clone(&protocol);
13909            let s = std::sync::Arc::clone(&state);
13910            handles.push(std::thread::spawn(move || {
13911                let head = format!("<HEAD-T{tid}>");
13912                let body = format!("<BODY-T{tid}>");
13913                let mut writer = TestWriter::new();
13914                let opts = RenderOptions::new("index.html", "/")
13915                    .with_head_inject(&head)
13916                    .with_body_inject(&body);
13917                h.handle(&p, &s, &opts, &mut writer).unwrap();
13918                let html = writer.get_content();
13919                // Must contain my own injects exactly once.
13920                assert_eq!(html.matches(&head).count(), 1);
13921                assert_eq!(html.matches(&body).count(), 1);
13922                // Must NOT contain any other thread's inject.
13923                for other in 0..N_THREADS {
13924                    if other == tid {
13925                        continue;
13926                    }
13927                    let other_head = format!("<HEAD-T{other}>");
13928                    let other_body = format!("<BODY-T{other}>");
13929                    assert!(
13930                        !html.contains(&other_head),
13931                        "tid {tid} saw {other}'s head_inject"
13932                    );
13933                    assert!(
13934                        !html.contains(&other_body),
13935                        "tid {tid} saw {other}'s body_inject"
13936                    );
13937                }
13938            }));
13939        }
13940        for h in handles {
13941            h.join().expect("worker panicked");
13942        }
13943    }
13944
13945    /// Coverage-17: a large (1 MiB) head_inject must round-trip
13946    /// correctly without panic, truncation, or excessive overhead.
13947    /// (No size cap is enforced by the handler — the host owns the
13948    /// safety contract; see `with_head_inject` doc comment.)
13949    #[test]
13950    fn large_inject_roundtrips_without_truncation() {
13951        let protocol = build_head_body_protocol();
13952        let state = test_json!({});
13953        let mut writer = TestWriter::new();
13954        let big = "x".repeat(1024 * 1024);
13955        let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
13956        handle(&protocol, &state, &opts, &mut writer).unwrap();
13957        let html = writer.get_content();
13958        assert!(
13959            html.contains(&big),
13960            "large head_inject must be present verbatim ({} bytes)",
13961            big.len()
13962        );
13963        // Sanity: only one copy.
13964        assert_eq!(html.matches(&big).count(), 1);
13965    }
13966
13967    /// `with_nonce("")` must normalize to `None` (no `<meta>` emitted),
13968    /// matching the empty-string semantics of `with_head_inject` /
13969    /// `with_body_inject`. An empty content attribute is browser-
13970    /// ignored noise.
13971    #[test]
13972    fn empty_nonce_treated_as_unset() {
13973        let protocol = build_head_body_protocol();
13974        let state = test_json!({});
13975        let mut writer = TestWriter::new();
13976        let opts = RenderOptions::new("index.html", "/").with_nonce("");
13977        handle(&protocol, &state, &opts, &mut writer).unwrap();
13978        assert!(
13979            !writer.get_content().contains("webui-nonce"),
13980            "empty nonce must not emit <meta name=\"webui-nonce\">"
13981        );
13982    }
13983
13984    /// Regression for the bug Akrosh caught: the `pub` fields on
13985    /// `RenderOptions` let a caller bypass the `with_*` builder
13986    /// normalisation, e.g.:
13987    ///
13988    /// ```ignore
13989    /// RenderOptions { nonce: Some(""), ..RenderOptions::new(e, p) }
13990    /// ```
13991    ///
13992    /// Without defensive normalisation at handler init, this would
13993    /// emit `<script nonce="">` on every inline script. Under a
13994    /// strict `Content-Security-Policy: script-src 'nonce-...'` an
13995    /// empty nonce is a HARD CSP failure that blocks every inline
13996    /// script — a complete inline-script-execution outage.
13997    ///
13998    /// The handler now treats `Some("")` identically to `None` for
13999    /// all three injection points (nonce / head_inject / body_inject)
14000    /// regardless of how the option was populated.
14001    #[test]
14002    fn empty_field_bypass_is_normalised_at_handler_init() {
14003        let protocol = build_head_body_protocol();
14004        let state = test_json!({});
14005
14006        // Bypass the `with_nonce` builder by writing the field directly.
14007        let opts_with_empty_nonce = RenderOptions {
14008            nonce: Some(""),
14009            ..RenderOptions::new("index.html", "/")
14010        };
14011        let mut writer = TestWriter::new();
14012        handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
14013        let html = writer.get_content();
14014        assert!(
14015            !html.contains("webui-nonce"),
14016            "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
14017        );
14018        assert!(
14019            !html.contains("nonce=\"\""),
14020            "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
14021        );
14022
14023        // Same defence for inject fields.
14024        let opts_with_empty_injects = RenderOptions {
14025            head_inject: Some(""),
14026            body_inject: Some(""),
14027            ..RenderOptions::new("index.html", "/")
14028        };
14029        let mut writer = TestWriter::new();
14030        handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
14031        // No assertion needed beyond "doesn't panic and doesn't emit
14032        // empty inject markers" — the head_end / body_end paths must
14033        // treat the empty inject as no-op the same way the builder does.
14034    }
14035
14036    /// Regression for the deep-audit's Bug-6 claim. The for-loop hot-
14037    /// path optimisation (insert key once + `get_mut`-swap value
14038    /// in-place) was suspected of corrupting the outer scope when a
14039    /// nested `<for>` loop reuses the same variable name. This test
14040    /// proves the optimisation is correct under that condition by
14041    /// requiring the outer `item` to be visible before, between, and
14042    /// after the inner loop, with its value preserved across inner
14043    /// iterations.
14044    ///
14045    /// Trace through the optimisation on `outer = [A, B]`,
14046    /// `inner = [X, Y]` with both loops using `item` as the variable:
14047    ///
14048    ///   outer pre-insert "item": Null
14049    ///   iter 1: get_mut → write A
14050    ///     emit "outer:A"
14051    ///     enter inner: saved = remove("item") = Some(A)
14052    ///                  pre-insert "item": Null
14053    ///                  iter 1: write X → emit "inner:X"
14054    ///                  iter 2: write Y → emit "inner:Y"
14055    ///                  restore: insert("item", A)   ← outer's A back
14056    ///     emit "outer:A again"               ← reads A correctly
14057    ///   iter 2: get_mut → write B (overwrites the restored A,
14058    ///                              but that's correct — we're now
14059    ///                              in iter 2 of the outer loop)
14060    ///     emit "outer:B"
14061    ///     enter inner: saved = remove("item") = Some(B), …, restore B
14062    ///     emit "outer:B again"
14063    ///
14064    /// If the audit's claim were correct — that the outer's `get_mut`
14065    /// somehow held a reference past the inner loop and clobbered the
14066    /// restoration — we'd see corrupted values in the "outer:X again"
14067    /// emissions. The assertion below pins the correct sequence.
14068    #[test]
14069    fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
14070        let mut fragments = HashMap::new();
14071        fragments.insert(
14072            "index.html".to_string(),
14073            FragmentList {
14074                fragments: vec![
14075                    WebUIFragment::raw("["),
14076                    WebUIFragment::for_loop("item", "outer", "outer_body"),
14077                    WebUIFragment::raw("]"),
14078                ],
14079                contains_boundary: false,
14080            },
14081        );
14082        fragments.insert(
14083            "outer_body".to_string(),
14084            FragmentList {
14085                fragments: vec![
14086                    WebUIFragment::raw("(O="),
14087                    WebUIFragment::signal("item.tag", false),
14088                    WebUIFragment::for_loop("item", "inner", "inner_body"),
14089                    WebUIFragment::raw(",O="),
14090                    WebUIFragment::signal("item.tag", false),
14091                    WebUIFragment::raw(")"),
14092                ],
14093                contains_boundary: false,
14094            },
14095        );
14096        fragments.insert(
14097            "inner_body".to_string(),
14098            FragmentList {
14099                fragments: vec![
14100                    WebUIFragment::raw("[I="),
14101                    WebUIFragment::signal("item.tag", false),
14102                    WebUIFragment::raw("]"),
14103                ],
14104                contains_boundary: false,
14105            },
14106        );
14107        let protocol = WebUIProtocol::new(fragments);
14108        let state = test_json!({
14109            "outer": [{"tag": "A"}, {"tag": "B"}],
14110            "inner": [{"tag": "X"}, {"tag": "Y"}],
14111        });
14112        let mut writer = TestWriter::new();
14113        handle(
14114            &protocol,
14115            &state,
14116            &RenderOptions::new("index.html", "/"),
14117            &mut writer,
14118        )
14119        .unwrap();
14120        // Expected sequence:
14121        //   outer iter 1 (item=A):
14122        //     emit "(O=A"               ← outer A before inner
14123        //     inner iter 1 (item=X) emit "[I=X]"
14124        //     inner iter 2 (item=Y) emit "[I=Y]"
14125        //     emit ",O=A)"              ← outer A AFTER inner restore
14126        //   outer iter 2 (item=B):
14127        //     emit "(O=B"
14128        //     inner iter 1 (item=X) emit "[I=X]"
14129        //     inner iter 2 (item=Y) emit "[I=Y]"
14130        //     emit ",O=B)"
14131        assert_eq!(
14132            writer.get_content(),
14133            "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
14134            "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
14135        );
14136    }
14137    #[derive(Default)]
14138    struct FlushTestWriter {
14139        output: String,
14140        flushes: Vec<usize>,
14141        fail_flush: bool,
14142        fail_flush_at: Option<usize>,
14143        flush_attempts: usize,
14144        ended: bool,
14145    }
14146
14147    impl ResponseWriter for FlushTestWriter {
14148        fn write(&mut self, content: &str) -> Result<()> {
14149            self.output.push_str(content);
14150            Ok(())
14151        }
14152
14153        fn end(&mut self) -> Result<()> {
14154            self.ended = true;
14155            Ok(())
14156        }
14157    }
14158
14159    impl FlushWriter for FlushTestWriter {
14160        fn flush(&mut self) -> Result<()> {
14161            let attempt = self.flush_attempts;
14162            self.flush_attempts += 1;
14163            if self.fail_flush || self.fail_flush_at == Some(attempt) {
14164                return Err(HandlerError::ClientDisconnected);
14165            }
14166            self.flushes.push(self.output.len());
14167            Ok(())
14168        }
14169    }
14170
14171    fn streaming_protocol(with_boundaries: bool) -> Protocol {
14172        streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
14173    }
14174
14175    fn streaming_protocol_with_state_strategy(
14176        with_boundaries: bool,
14177        state_strategy: InitialStateStrategy,
14178    ) -> Protocol {
14179        let mut fragments = HashMap::new();
14180        let mut entry = vec![
14181            WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
14182            structural_fragment("head_start"),
14183            WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
14184            structural_fragment("head_end"),
14185            WebUIFragment::raw("</HEAD><body>"),
14186            structural_fragment("body_start"),
14187        ];
14188        if with_boundaries {
14189            entry.push(WebUIFragment::boundary(0, "index.html", "boundary-0", None));
14190        }
14191        entry.extend([
14192            WebUIFragment::raw("<my-counter"),
14193            structural_fragment("streaming_root:my-counter"),
14194            WebUIFragment::raw(">"),
14195            WebUIFragment::component("my-counter"),
14196            WebUIFragment::raw("</my-counter>"),
14197        ]);
14198        if with_boundaries {
14199            entry.push(WebUIFragment::boundary_end(0));
14200        }
14201        entry.extend([
14202            WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
14203            structural_fragment("body_end"),
14204            WebUIFragment::raw("</body></html>"),
14205        ]);
14206        fragments.insert(
14207            "index.html".to_string(),
14208            FragmentList {
14209                fragments: entry,
14210                contains_boundary: with_boundaries,
14211            },
14212        );
14213        fragments.insert(
14214            "my-counter".to_string(),
14215            FragmentList {
14216                fragments: vec![WebUIFragment::raw("<button>Count</button>")],
14217                contains_boundary: false,
14218            },
14219        );
14220
14221        let mut document = WebUIProtocol::new(fragments);
14222        document.initial_state_strategy = state_strategy as i32;
14223        document.components.insert(
14224            "my-counter".to_string(),
14225            webui_protocol::ComponentData {
14226                template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
14227                template_functions: "[function(){return true}]".to_string(),
14228                hydration_mode: StateProjectionMode::Keys as i32,
14229                hydration_keys: vec!["count".to_string()],
14230                ..Default::default()
14231            },
14232        );
14233        Protocol::new(document)
14234    }
14235
14236    #[test]
14237    fn handler_error_stays_small() {
14238        // Boxing the cold `StreamingBoundary` payload keeps `HandlerError` — and
14239        // therefore `Result<(), HandlerError>` threaded through the hot legacy
14240        // render path — down to a single `String`-sized payload plus a
14241        // discriminant word. If the boundary payload is un-boxed back to
14242        // `{ signal, reason }` it grows to two `String`s (48-byte payload) and
14243        // this fails.
14244        assert!(
14245            std::mem::size_of::<HandlerError>()
14246                <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
14247            "HandlerError grew to {} bytes",
14248            std::mem::size_of::<HandlerError>()
14249        );
14250    }
14251
14252    #[test]
14253    fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
14254        let protocol = streaming_protocol(true);
14255        let handler = WebUIHandler::with_plugin(|| {
14256            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14257        });
14258        let state = test_json!({ "count": 1, "serverOnly": "secret" });
14259        let mut writer = FlushTestWriter::default();
14260
14261        handler
14262            .render_streaming(
14263                &protocol,
14264                &state,
14265                &RenderOptions::new("index.html", "/"),
14266                &mut writer,
14267            )
14268            .unwrap();
14269
14270        assert!(writer.ended);
14271        assert_eq!(
14272            writer.flushes.len(),
14273            3,
14274            "bootstrap, boundary commit, and one coalesced terminal-tail flush"
14275        );
14276        let boundary_flush = &writer.output[writer.flushes[0]..writer.flushes[1]];
14277        assert!(boundary_flush.contains("<!--wb:0-->"));
14278        assert!(boundary_flush.contains("<!--/wb:0-->"));
14279        assert!(
14280            boundary_flush.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
14281            "boundary flush: {boundary_flush}"
14282        );
14283        assert!(boundary_flush.contains(r#""inventory":"01","state":{"count":1}"#));
14284        assert!(boundary_flush.contains(r#""templates":{"my-counter":"#));
14285        assert!(!boundary_flush.contains("slow tail"));
14286        assert!(!writer.output.contains("id=\"webui-data\""));
14287        // The terminal flush commits the scriptless tail without manufacturing
14288        // another state/template projection.
14289        let terminal_flush = &writer.output[writer.flushes[1]..writer.flushes[2]];
14290        assert!(terminal_flush.contains("slow tail"));
14291        assert!(writer.output.contains("[1,4,0,{}]"));
14292        assert!(!writer.output.contains("[1,0,1,"));
14293        assert!(!writer.output.contains("[2,4,0,{}]"));
14294
14295        let marker = writer
14296            .output
14297            .find(STREAMING_MARKER)
14298            .expect("streaming marker");
14299        let attributed_head = writer
14300            .output
14301            .find("<HEAD data-shell=\"main\">")
14302            .expect("attributed mixed-case head");
14303        let authored_script = writer
14304            .output
14305            .find("src=\"/index.js\"")
14306            .expect("entry script");
14307        assert!(attributed_head < marker && marker < authored_script);
14308    }
14309
14310    #[test]
14311    fn streaming_terminal_tail_never_resends_full_state() {
14312        let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
14313        let handler = WebUIHandler::with_plugin(|| {
14314            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14315        });
14316        let mut writer = FlushTestWriter::default();
14317
14318        handler
14319            .render_streaming(
14320                &protocol,
14321                &test_json!({ "count": 1, "serverOnly": "secret" }),
14322                &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
14323                &mut writer,
14324            )
14325            .unwrap();
14326
14327        assert_eq!(
14328            writer.output.matches("serverOnly").count(),
14329            1,
14330            "full state belongs only to the interactive boundary"
14331        );
14332        assert!(writer.output.contains("[1,4,0,{}]"));
14333    }
14334
14335    #[test]
14336    fn streaming_terminal_record_emits_before_body_close() {
14337        let fragments = HashMap::from([(
14338            "index.html".to_string(),
14339            FragmentList {
14340                fragments: vec![
14341                    WebUIFragment::raw("<html><head>"),
14342                    structural_fragment("head_start"),
14343                    structural_fragment("head_end"),
14344                    WebUIFragment::raw("</head><body>"),
14345                    structural_fragment("body_start"),
14346                    WebUIFragment::raw("<main>static</main>"),
14347                    structural_fragment("body_end"),
14348                    WebUIFragment::raw("</body></html>"),
14349                ],
14350                contains_boundary: false,
14351            },
14352        )]);
14353        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14354        let mut writer = FlushTestWriter::default();
14355
14356        WebUIHandler::new()
14357            .render_streaming(
14358                &protocol,
14359                &test_json!({}),
14360                &RenderOptions::new("index.html", "/"),
14361                &mut writer,
14362            )
14363            .unwrap();
14364
14365        let terminal = writer.output.find("[0,4,0,{}]").expect("terminal");
14366        let body_close = writer.output.find("</body>").expect("body close");
14367        assert!(
14368            terminal < body_close,
14369            "terminal boundary record must stay inside <body>: {}",
14370            writer.output
14371        );
14372    }
14373
14374    /// Streaming must place the reserved-state injects exactly where the
14375    /// ordinary render does, so a host can switch modes without its
14376    /// boundary HTML moving.
14377    #[test]
14378    fn state_inject_placement_matches_between_render_modes() {
14379        let entry = vec![
14380            WebUIFragment::raw("<html><head>"),
14381            structural_fragment("head_start"),
14382            structural_fragment("head_end"),
14383            WebUIFragment::raw("</head><body>"),
14384            structural_fragment("body_start"),
14385            WebUIFragment::raw("<main>static</main>"),
14386            structural_fragment("body_end"),
14387            WebUIFragment::raw("</body></html>"),
14388        ];
14389        let fragments = HashMap::from([(
14390            "index.html".to_string(),
14391            FragmentList {
14392                fragments: entry,
14393                contains_boundary: false,
14394            },
14395        )]);
14396        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14397        let state = test_json!({
14398            "$webui": {
14399                "headEnd": "<meta name=he>",
14400                "bodyStart": "<span id=bs></span>",
14401                "bodyEnd": "<script>be</script>",
14402            }
14403        });
14404        let options = RenderOptions::new("index.html", "/");
14405
14406        let mut ordinary = TestWriter::new();
14407        WebUIHandler::new()
14408            .render(&protocol, &state, &options, &mut ordinary)
14409            .unwrap();
14410        let ordinary_html = ordinary.get_content().to_string();
14411
14412        let mut streamed = FlushTestWriter::default();
14413        WebUIHandler::new()
14414            .render_streaming(&protocol, &state, &options, &mut streamed)
14415            .unwrap();
14416        let streamed_html = &streamed.output;
14417
14418        for html in [ordinary_html.as_str(), streamed_html.as_str()] {
14419            let head_end = html.find("<meta name=he>").expect("headEnd missing");
14420            let head_close = html.find("</head>").expect("</head> missing");
14421            let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
14422            let main = html.find("<main>static</main>").expect("content missing");
14423            let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
14424            let body_close = html.find("</body>").expect("</body> missing");
14425            assert!(head_end < head_close, "headEnd misplaced: {html}");
14426            assert!(body_start < main, "bodyStart misplaced: {html}");
14427            assert!(
14428                main < body_end && body_end < body_close,
14429                "bodyEnd misplaced: {html}"
14430            );
14431        }
14432
14433        // The streaming response still terminates with its single empty
14434        // terminal record: an inject must not perturb the record stream.
14435        assert!(
14436            streamed_html.contains(",4,0,{}]"),
14437            "streaming must still end in one empty terminal record: {streamed_html}"
14438        );
14439    }
14440
14441    #[test]
14442    fn streaming_state_inject_emits_and_strips_reserved_key() {
14443        let entry = vec![
14444            WebUIFragment::raw("<html><head>"),
14445            structural_fragment("head_start"),
14446            structural_fragment("head_end"),
14447            WebUIFragment::raw("</head><body>"),
14448            structural_fragment("body_start"),
14449            structural_fragment("body_end"),
14450            WebUIFragment::raw("</body></html>"),
14451        ];
14452        let fragments = HashMap::from([(
14453            "index.html".to_string(),
14454            FragmentList {
14455                fragments: entry,
14456                contains_boundary: false,
14457            },
14458        )]);
14459        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14460        let state = test_json!({ "$webui": { "bodyEnd": "<script>be</script>" } });
14461
14462        let mut writer = FlushTestWriter::default();
14463        WebUIHandler::new()
14464            .render_streaming(
14465                &protocol,
14466                &state,
14467                &RenderOptions::new("index.html", "/"),
14468                &mut writer,
14469            )
14470            .unwrap();
14471        assert!(writer.output.contains("<script>be</script>"));
14472        assert!(!writer.output.contains("$webui"));
14473    }
14474
14475    #[test]
14476    fn static_streaming_document_uses_one_empty_terminal_record() {
14477        let fragments = HashMap::from([(
14478            "index.html".to_string(),
14479            FragmentList {
14480                fragments: vec![
14481                    WebUIFragment::raw("<html><head>"),
14482                    structural_fragment("head_start"),
14483                    structural_fragment("head_end"),
14484                    WebUIFragment::raw("</head><body>"),
14485                    structural_fragment("body_start"),
14486                    WebUIFragment::raw("<main>static</main>"),
14487                    structural_fragment("body_end"),
14488                    WebUIFragment::raw("</body></html>"),
14489                ],
14490                contains_boundary: false,
14491            },
14492        )]);
14493        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14494        let mut writer = FlushTestWriter::default();
14495
14496        WebUIHandler::new()
14497            .render_streaming(
14498                &protocol,
14499                &test_json!({ "serverOnly": "secret" }),
14500                &RenderOptions::new("index.html", "/"),
14501                &mut writer,
14502            )
14503            .unwrap();
14504
14505        assert_eq!(writer.flushes.len(), 1);
14506        assert!(writer.output.contains(STREAMING_MARKER));
14507        assert!(writer.output.contains("[0,4,0,{}]"));
14508        assert!(!writer.output.contains("serverOnly"));
14509        assert!(!writer.output.contains("id=\"webui-data\""));
14510        assert!(!writer.output.contains("<!--wb:"));
14511    }
14512
14513    #[test]
14514    fn streaming_render_emits_nonce_and_functions_before_sentinel() {
14515        let protocol = streaming_protocol(true);
14516        let handler = WebUIHandler::with_plugin(|| {
14517            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14518        });
14519        let mut writer = FlushTestWriter::default();
14520        handler
14521            .render_streaming(
14522                &protocol,
14523                &test_json!({ "count": 1 }),
14524                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
14525                &mut writer,
14526            )
14527            .unwrap();
14528
14529        let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
14530        let envelope = writer.output[boundary_end..]
14531            .find("data-webui-boundary nonce=\"test-nonce-123\"")
14532            .map(|index| index + boundary_end)
14533            .expect("nonce-bearing envelope");
14534        let functions = writer
14535            .output
14536            .find("templateFns")
14537            .expect("function side channel");
14538        let sentinel = writer
14539            .output
14540            .find("<webui-hydrate>")
14541            .expect("hydration sentinel");
14542        assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
14543        assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
14544    }
14545
14546    #[test]
14547    fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
14548        fn protocol(boundaries: Vec<WebUIFragment>) -> Protocol {
14549            let mut fragments = vec![structural_fragment("head_start")];
14550            fragments.extend(boundaries);
14551            let fragments = HashMap::from([(
14552                "index.html".to_string(),
14553                FragmentList {
14554                    fragments,
14555                    contains_boundary: true,
14556                },
14557            )]);
14558            Protocol::new(WebUIProtocol::new(fragments))
14559        }
14560
14561        let handler = WebUIHandler::new();
14562        let mut writer = FlushTestWriter::default();
14563        let result = handler.render_streaming(
14564            &protocol(vec![WebUIFragment::boundary_end(0)]),
14565            &test_json!({}),
14566            &RenderOptions::new("index.html", "/"),
14567            &mut writer,
14568        );
14569        match result {
14570            Err(HandlerError::StreamingBoundary(err)) => {
14571                assert_eq!(err.signal, "boundary");
14572                assert!(err.reason.contains("no active occurrence"));
14573            }
14574            other => panic!("expected ordered-boundary error, got {other:?}"),
14575        }
14576
14577        let mut writer = FlushTestWriter::default();
14578        let result = handler.render_streaming(
14579            &protocol(vec![
14580                WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14581                WebUIFragment::boundary(1, "index.html", "boundary-1", None),
14582            ]),
14583            &test_json!({}),
14584            &RenderOptions::new("index.html", "/"),
14585            &mut writer,
14586        );
14587        assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
14588    }
14589
14590    #[test]
14591    fn streaming_render_propagates_boundary_flush_disconnect() {
14592        let protocol = streaming_protocol(true);
14593        let handler = WebUIHandler::new();
14594        let mut writer = FlushTestWriter {
14595            fail_flush: true,
14596            ..FlushTestWriter::default()
14597        };
14598        let result = handler.render_streaming(
14599            &protocol,
14600            &test_json!({}),
14601            &RenderOptions::new("index.html", "/"),
14602            &mut writer,
14603        );
14604        assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
14605        assert!(!writer.ended);
14606    }
14607
14608    #[test]
14609    fn streaming_render_requires_structural_head_start_before_writing() {
14610        let fragments = HashMap::from([(
14611            "index.html".to_string(),
14612            FragmentList {
14613                fragments: vec![
14614                    WebUIFragment::raw(
14615                        "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
14616                    ),
14617                    structural_fragment("head_end"),
14618                    WebUIFragment::raw("</HEAD><body>"),
14619                    structural_fragment("body_start"),
14620                    structural_fragment("body_end"),
14621                ],
14622                contains_boundary: false,
14623            },
14624        )]);
14625        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14626        let mut writer = FlushTestWriter::default();
14627
14628        let result = WebUIHandler::new().render_streaming(
14629            &protocol,
14630            &test_json!({}),
14631            &RenderOptions::new("index.html", "/"),
14632            &mut writer,
14633        );
14634
14635        assert!(matches!(
14636            result,
14637            Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
14638        ));
14639        assert_eq!(
14640            writer.output,
14641            "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>"
14642        );
14643        assert!(writer.flushes.is_empty());
14644        assert!(!writer.ended);
14645    }
14646
14647    #[test]
14648    fn streaming_render_rejects_duplicate_head_start() {
14649        let fragments = HashMap::from([(
14650            "index.html".to_string(),
14651            FragmentList {
14652                fragments: vec![
14653                    WebUIFragment::raw("<html><head>"),
14654                    structural_fragment("head_start"),
14655                    structural_fragment("head_start"),
14656                    structural_fragment("head_end"),
14657                    WebUIFragment::raw("</head><body>"),
14658                    structural_fragment("body_start"),
14659                    structural_fragment("body_end"),
14660                ],
14661                contains_boundary: false,
14662            },
14663        )]);
14664        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14665        let mut writer = FlushTestWriter::default();
14666
14667        let result = WebUIHandler::new().render_streaming(
14668            &protocol,
14669            &test_json!({}),
14670            &RenderOptions::new("index.html", "/"),
14671            &mut writer,
14672        );
14673
14674        assert!(matches!(
14675            result,
14676            Err(HandlerError::DuplicateStreamingHeadStart)
14677        ));
14678        assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
14679        assert!(writer.flushes.is_empty());
14680    }
14681
14682    #[test]
14683    fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
14684        let handler = WebUIHandler::with_plugin(|| {
14685            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14686        });
14687        let state = test_json!({
14688            "count": 1,
14689            "head_start": "must not render",
14690            "boundary_start:0": "must not render",
14691            "boundary_end:0": "must not render",
14692        });
14693        let mut with_boundaries = TestWriter::new();
14694        let mut without_boundaries = TestWriter::new();
14695        handler
14696            .render(
14697                &streaming_protocol(true),
14698                &state,
14699                &RenderOptions::new("index.html", "/"),
14700                &mut with_boundaries,
14701            )
14702            .unwrap();
14703        handler
14704            .render(
14705                &streaming_protocol(false),
14706                &state,
14707                &RenderOptions::new("index.html", "/"),
14708                &mut without_boundaries,
14709            )
14710            .unwrap();
14711        assert_eq!(
14712            with_boundaries.get_content(),
14713            without_boundaries.get_content()
14714        );
14715    }
14716
14717    #[test]
14718    fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
14719        for source in [
14720            r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
14721            r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
14722        ] {
14723            let mut parser = HtmlParser::new();
14724            parser
14725                .parse("index.html", source)
14726                .expect("parse head fixture");
14727            let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14728            let mut writer = TestWriter::new();
14729            WebUIHandler::new()
14730                .render(
14731                    &protocol,
14732                    &test_json!({ "theme": "light" }),
14733                    &RenderOptions::new("index.html", "/"),
14734                    &mut writer,
14735                )
14736                .unwrap();
14737
14738            assert_eq!(
14739                writer.get_content(),
14740                "<html><head><title>T</title></head><body>x</body></html>"
14741            );
14742        }
14743    }
14744
14745    #[test]
14746    fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
14747        let source =
14748            r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
14749        let mut parser = HtmlParser::new();
14750        parser.parse("index.html", source).expect("parse fixture");
14751        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14752
14753        let mut ordinary = TestWriter::new();
14754        WebUIHandler::new()
14755            .render(
14756                &protocol,
14757                &test_json!({}),
14758                &RenderOptions::new("index.html", "/"),
14759                &mut ordinary,
14760            )
14761            .unwrap();
14762        assert_eq!(ordinary.get_content(), source);
14763
14764        let mut streaming = FlushTestWriter::default();
14765        WebUIHandler::new()
14766            .render_streaming(
14767                &protocol,
14768                &test_json!({}),
14769                &RenderOptions::new("index.html", "/"),
14770                &mut streaming,
14771            )
14772            .unwrap();
14773        assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
14774        assert!(streaming.output.contains("</HEAD><BODY>x"));
14775        let opening = streaming.output.find("<HEAD").expect("mixed-case head");
14776        let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
14777        let title = streaming.output.find("<title>").expect("title");
14778        assert!(opening < marker && marker < title);
14779        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14780    }
14781
14782    /// Only `}}}webui:`-namespaced signals are compiler-owned, so an
14783    /// unprefixed `body_end` is ordinary authored content.
14784    #[test]
14785    fn unnamespaced_signal_is_ordinary_content() {
14786        let fragments = HashMap::from([(
14787            "index.html".to_string(),
14788            FragmentList {
14789                fragments: vec![
14790                    WebUIFragment::raw("<html><head>"),
14791                    structural_fragment("head_end"),
14792                    WebUIFragment::raw("</head><body>"),
14793                    WebUIFragment::signal("body_end".to_string(), false),
14794                    structural_fragment("body_end"),
14795                    WebUIFragment::raw("</body></html>"),
14796                ],
14797                contains_boundary: false,
14798            },
14799        )]);
14800        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14801        let mut writer = TestWriter::new();
14802        WebUIHandler::new()
14803            .render(
14804                &protocol,
14805                &test_json!({ "body_end": "content" }),
14806                &RenderOptions::new("index.html", "/"),
14807                &mut writer,
14808            )
14809            .expect("current protocol must render");
14810        assert!(writer.get_content().contains("content"));
14811    }
14812
14813    #[test]
14814    fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
14815        let source = concat!(
14816            "<html><head></head><body>",
14817            "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
14818            "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
14819            "</body></html>",
14820        );
14821        let state = test_json!({
14822            "head_start": "hs",
14823            "head_end": "he",
14824            "body_start": "bs",
14825            "body_end": "be",
14826            "boundary_start:0": "b0s",
14827            "boundary_end:0": "b0e",
14828            "streaming_root:forged": "root",
14829        });
14830        let mut parser = HtmlParser::new();
14831        parser.parse("index.html", source).expect("parse fixture");
14832        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14833        let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
14834
14835        let mut ordinary = TestWriter::new();
14836        WebUIHandler::new()
14837            .render(
14838                &protocol,
14839                &state,
14840                &RenderOptions::new("index.html", "/"),
14841                &mut ordinary,
14842            )
14843            .unwrap();
14844        assert_eq!(ordinary.get_content(), expected);
14845
14846        let mut streaming = FlushTestWriter::default();
14847        WebUIHandler::new()
14848            .render_streaming(
14849                &protocol,
14850                &state,
14851                &RenderOptions::new("index.html", "/"),
14852                &mut streaming,
14853            )
14854            .unwrap();
14855        assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
14856        assert!(!streaming.output.contains("<!--wb:"));
14857        assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
14858        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14859    }
14860
14861    #[test]
14862    fn streaming_rejects_structural_signals_after_terminal() {
14863        let fragments = HashMap::from([(
14864            "index.html".to_string(),
14865            FragmentList {
14866                fragments: vec![
14867                    WebUIFragment::raw("<html><head>"),
14868                    structural_fragment("head_start"),
14869                    structural_fragment("head_end"),
14870                    WebUIFragment::raw("</head><body>"),
14871                    structural_fragment("body_start"),
14872                    structural_fragment("body_end"),
14873                    WebUIFragment::raw("</body>"),
14874                    structural_fragment("boundary_start:0"),
14875                    WebUIFragment::raw("</html>"),
14876                ],
14877                contains_boundary: false,
14878            },
14879        )]);
14880        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14881        let mut streaming = FlushTestWriter::default();
14882        let result = WebUIHandler::new().render_streaming(
14883            &protocol,
14884            &test_json!({}),
14885            &RenderOptions::new("index.html", "/"),
14886            &mut streaming,
14887        );
14888
14889        match result {
14890            Err(HandlerError::StreamingBoundary(error)) => {
14891                assert_eq!(error.signal, "boundary_start:0");
14892                assert!(
14893                    error
14894                        .reason
14895                        .contains("structural signal arrived after body_end"),
14896                    "reason: {}",
14897                    error.reason
14898                );
14899            }
14900            other => panic!("expected post-terminal rejection, got {other:?}"),
14901        }
14902        assert_eq!(
14903            streaming.output,
14904            concat!(
14905                "<html><head><meta name=\"webui-streaming\" content=\"1\"></head><body>",
14906                "<script type=\"application/json\" data-webui-boundary>[0,4,0,{}]</script>",
14907                "<webui-hydrate></webui-hydrate></body>"
14908            )
14909        );
14910        assert!(!streaming.output.contains("<!--wb:0-->"));
14911
14912        let mut ordinary = TestWriter::new();
14913        WebUIHandler::new()
14914            .render(
14915                &protocol,
14916                &test_json!({}),
14917                &RenderOptions::new("index.html", "/"),
14918                &mut ordinary,
14919            )
14920            .unwrap();
14921        assert_eq!(
14922            ordinary.get_content(),
14923            "<html><head></head><body></body></html>"
14924        );
14925    }
14926
14927    fn disjoint_streaming_protocol_ext(
14928        hosts: &[&str],
14929        emit_root_signal: bool,
14930        include_styles: bool,
14931    ) -> Protocol {
14932        let mut fragments = HashMap::new();
14933        let mut entry = vec![
14934            WebUIFragment::raw("<!DOCTYPE html><html><head>"),
14935            structural_fragment("head_start"),
14936            structural_fragment("head_end"),
14937            WebUIFragment::raw("</head><body>"),
14938            structural_fragment("body_start"),
14939        ];
14940        for (sequence, host) in hosts.iter().enumerate() {
14941            entry.push(WebUIFragment::boundary(
14942                sequence as u32,
14943                "index.html",
14944                format!("boundary-{sequence}"),
14945                None,
14946            ));
14947            entry.push(WebUIFragment::raw(format!("<{host}")));
14948            if emit_root_signal {
14949                entry.push(structural_fragment(format!("streaming_root:{host}")));
14950            }
14951            entry.push(WebUIFragment::raw(">"));
14952            entry.push(WebUIFragment::component(*host));
14953            entry.push(WebUIFragment::raw(format!("</{host}>")));
14954            entry.push(WebUIFragment::boundary_end(sequence as u32));
14955        }
14956        entry.push(structural_fragment("body_end"));
14957        entry.push(WebUIFragment::raw("</body></html>"));
14958        fragments.insert(
14959            "index.html".to_string(),
14960            FragmentList {
14961                fragments: entry,
14962                contains_boundary: true,
14963            },
14964        );
14965        fragments.insert(
14966            "comp-a".to_string(),
14967            FragmentList {
14968                fragments: vec![WebUIFragment::raw("<b>A</b>")],
14969                contains_boundary: false,
14970            },
14971        );
14972        fragments.insert(
14973            "comp-b".to_string(),
14974            FragmentList {
14975                fragments: vec![WebUIFragment::raw("<b>B</b>")],
14976                contains_boundary: false,
14977            },
14978        );
14979
14980        let mut document = WebUIProtocol::new(fragments);
14981        document.initial_state_strategy = InitialStateStrategy::Components as i32;
14982        document.components.insert(
14983            "comp-a".to_string(),
14984            webui_protocol::ComponentData {
14985                template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
14986                hydration_mode: StateProjectionMode::Keys as i32,
14987                hydration_keys: vec!["a_count".to_string()],
14988                ..Default::default()
14989            },
14990        );
14991        document.components.insert(
14992            "comp-b".to_string(),
14993            webui_protocol::ComponentData {
14994                template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
14995                hydration_mode: StateProjectionMode::Keys as i32,
14996                hydration_keys: vec!["b_count".to_string()],
14997                ..Default::default()
14998            },
14999        );
15000        if include_styles {
15001            document.set_css_strategy(webui_protocol::CssStrategy::Style);
15002            document
15003                .components
15004                .get_mut("comp-a")
15005                .expect("comp-a component")
15006                .css = ".comp-a{color:red}".to_string();
15007            document
15008                .components
15009                .get_mut("comp-b")
15010                .expect("comp-b component")
15011                .css = ".comp-b{color:blue}".to_string();
15012            document.populate_style_closures(&["index.html"]);
15013        }
15014        Protocol::new(document)
15015    }
15016
15017    fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
15018        disjoint_streaming_protocol_ext(hosts, true, false)
15019    }
15020
15021    fn styled_disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
15022        disjoint_streaming_protocol_ext(hosts, true, true)
15023    }
15024
15025    fn streaming_root_validation_protocol(
15026        mut host_fragments: Vec<WebUIFragment>,
15027        tail: Vec<WebUIFragment>,
15028    ) -> Protocol {
15029        let mut entry = vec![
15030            WebUIFragment::raw("<html><head>"),
15031            structural_fragment("head_start"),
15032            structural_fragment("head_end"),
15033            WebUIFragment::raw("</head><body>"),
15034            structural_fragment("body_start"),
15035            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15036        ];
15037        entry.append(&mut host_fragments);
15038        entry.extend(tail);
15039        let fragments = HashMap::from([
15040            (
15041                "index.html".to_string(),
15042                FragmentList {
15043                    fragments: entry,
15044                    contains_boundary: true,
15045                },
15046            ),
15047            (
15048                "comp-a".to_string(),
15049                FragmentList {
15050                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
15051                    contains_boundary: false,
15052                },
15053            ),
15054            (
15055                "comp-b".to_string(),
15056                FragmentList {
15057                    fragments: vec![WebUIFragment::raw("<b>B</b>")],
15058                    contains_boundary: false,
15059                },
15060            ),
15061        ]);
15062        Protocol::new(WebUIProtocol::new(fragments))
15063    }
15064
15065    fn completed_streaming_tail() -> Vec<WebUIFragment> {
15066        vec![
15067            WebUIFragment::raw("</comp-a>"),
15068            WebUIFragment::boundary_end(0),
15069            structural_fragment("body_end"),
15070            WebUIFragment::raw("</body></html>"),
15071        ]
15072    }
15073
15074    fn assert_streaming_root_error(
15075        protocol: &Protocol,
15076        expected_signal: &str,
15077        expected_reason: &str,
15078    ) {
15079        let mut writer = FlushTestWriter::default();
15080        let result = WebUIHandler::new().render_streaming(
15081            protocol,
15082            &test_json!({}),
15083            &RenderOptions::new("index.html", "/"),
15084            &mut writer,
15085        );
15086        match result {
15087            Err(HandlerError::StreamingBoundary(error)) => {
15088                assert_eq!(error.signal, expected_signal);
15089                assert!(
15090                    error.reason.contains(expected_reason),
15091                    "reason: {}",
15092                    error.reason
15093                );
15094            }
15095            other => panic!("expected streaming-root rejection, got {other:?}"),
15096        }
15097    }
15098
15099    #[test]
15100    fn streaming_root_signal_injects_data_ws_inside_boundary() {
15101        let protocol = disjoint_streaming_protocol(&["comp-a"]);
15102        let handler = WebUIHandler::with_plugin(|| {
15103            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15104        });
15105        let mut writer = FlushTestWriter::default();
15106        handler
15107            .render_streaming(
15108                &protocol,
15109                &test_json!({ "a_count": 1 }),
15110                &RenderOptions::new("index.html", "/"),
15111                &mut writer,
15112            )
15113            .unwrap();
15114        // The parser-owned signal is consumed to inject exactly ` data-ws`
15115        // inside the host's opening tag, before the custom element upgrades.
15116        assert!(
15117            writer.output.contains("<comp-a data-ws>"),
15118            "streamed host must carry data-ws: {}",
15119            writer.output
15120        );
15121    }
15122
15123    #[test]
15124    fn streaming_root_signal_preserves_ordinary_output_bytes() {
15125        // Ordinary rendering ignores `streaming_root` byte-for-byte: identical
15126        // output with and without the signal, and never a `data-ws` attribute.
15127        let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true, false);
15128        let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false, false);
15129        let state = test_json!({ "a_count": 1, "b_count": 2 });
15130        let plugin = || {
15131            WebUIHandler::with_plugin(
15132                || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
15133            )
15134        };
15135        let mut with_writer = TestWriter::new();
15136        let mut without_writer = TestWriter::new();
15137        plugin()
15138            .render(
15139                &with_signal,
15140                &state,
15141                &RenderOptions::new("index.html", "/"),
15142                &mut with_writer,
15143            )
15144            .unwrap();
15145        plugin()
15146            .render(
15147                &without_signal,
15148                &state,
15149                &RenderOptions::new("index.html", "/"),
15150                &mut without_writer,
15151            )
15152            .unwrap();
15153        // The rendered DOM (everything up to the inert data block) is
15154        // deterministic and is where a leaked `data-ws` would appear. The
15155        // ordinary template map is HashSet-ordered, so compare the DOM prefix
15156        // for byte identity rather than the whole document.
15157        let dom_prefix = |content: &str| -> String {
15158            content
15159                .split_once(r#"<script type="application/json" id="webui-data""#)
15160                .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
15161        };
15162        assert_eq!(
15163            dom_prefix(&with_writer.get_content()),
15164            dom_prefix(&without_writer.get_content())
15165        );
15166        assert!(!with_writer.get_content().contains("data-ws"));
15167        assert!(with_writer.get_content().contains("<comp-a>"));
15168    }
15169
15170    #[test]
15171    fn streaming_component_without_root_signal_is_rejected() {
15172        let protocol = streaming_root_validation_protocol(
15173            vec![
15174                WebUIFragment::raw("<comp-a>"),
15175                WebUIFragment::component("comp-a"),
15176            ],
15177            completed_streaming_tail(),
15178        );
15179
15180        assert_streaming_root_error(
15181            &protocol,
15182            "comp-a",
15183            "has no compiler-owned streaming_root signal",
15184        );
15185    }
15186
15187    #[test]
15188    fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
15189        let duplicate = streaming_root_validation_protocol(
15190            vec![
15191                WebUIFragment::raw("<comp-a"),
15192                structural_fragment("streaming_root:comp-a"),
15193                structural_fragment("streaming_root:comp-a"),
15194                WebUIFragment::raw(">"),
15195                WebUIFragment::component("comp-a"),
15196            ],
15197            completed_streaming_tail(),
15198        );
15199        assert_streaming_root_error(&duplicate, "comp-a", "is misplaced");
15200
15201        let after_tag_close = streaming_root_validation_protocol(
15202            vec![
15203                WebUIFragment::raw("<comp-a>"),
15204                structural_fragment("streaming_root:comp-a"),
15205                WebUIFragment::component("comp-a"),
15206            ],
15207            completed_streaming_tail(),
15208        );
15209        assert_streaming_root_error(&after_tag_close, "comp-a", "is misplaced");
15210
15211        let detached_from_opening = streaming_root_validation_protocol(
15212            vec![
15213                WebUIFragment::raw("not an opening tag"),
15214                structural_fragment("streaming_root:comp-a"),
15215                WebUIFragment::raw(">"),
15216                WebUIFragment::component("comp-a"),
15217            ],
15218            completed_streaming_tail(),
15219        );
15220        assert_streaming_root_error(
15221            &detached_from_opening,
15222            "comp-a",
15223            "unclosed component opening-tag close",
15224        );
15225    }
15226
15227    #[test]
15228    fn mismatched_streaming_root_signal_is_rejected() {
15229        let protocol = streaming_root_validation_protocol(
15230            vec![
15231                WebUIFragment::raw("<comp-a"),
15232                structural_fragment("streaming_root:comp-b"),
15233                WebUIFragment::raw(">"),
15234                WebUIFragment::component("comp-a"),
15235            ],
15236            completed_streaming_tail(),
15237        );
15238
15239        assert_streaming_root_error(&protocol, "comp-b", "root signal names <comp-b>");
15240    }
15241
15242    #[test]
15243    fn pending_streaming_root_is_rejected_at_structural_ends() {
15244        let protocol = streaming_root_validation_protocol(
15245            vec![
15246                WebUIFragment::raw("<comp-a"),
15247                structural_fragment("streaming_root:comp-a"),
15248                WebUIFragment::raw(">"),
15249            ],
15250            vec![structural_fragment("boundary_end:0")],
15251        );
15252        assert_streaming_root_error(&protocol, "comp-a", "is misplaced");
15253    }
15254
15255    #[test]
15256    fn streaming_root_outside_boundary_is_rejected() {
15257        let fragments = HashMap::from([
15258            (
15259                "index.html".to_string(),
15260                FragmentList {
15261                    fragments: vec![
15262                        WebUIFragment::raw("<html><head>"),
15263                        structural_fragment("head_start"),
15264                        structural_fragment("head_end"),
15265                        WebUIFragment::raw("</head><body>"),
15266                        structural_fragment("body_start"),
15267                        WebUIFragment::raw("<comp-a"),
15268                        structural_fragment("streaming_root:comp-a"),
15269                        WebUIFragment::raw(">"),
15270                        WebUIFragment::component("comp-a"),
15271                        WebUIFragment::raw("</comp-a>"),
15272                        structural_fragment("body_end"),
15273                        WebUIFragment::raw("</body></html>"),
15274                    ],
15275                    contains_boundary: false,
15276                },
15277            ),
15278            (
15279                "comp-a".to_string(),
15280                FragmentList {
15281                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
15282                    contains_boundary: false,
15283                },
15284            ),
15285        ]);
15286        let protocol = Protocol::new(WebUIProtocol::new(fragments));
15287        let mut writer = FlushTestWriter::default();
15288        let result = WebUIHandler::new().render_streaming(
15289            &protocol,
15290            &test_json!({}),
15291            &RenderOptions::new("index.html", "/"),
15292            &mut writer,
15293        );
15294        match result {
15295            Err(HandlerError::StreamingBoundary(err)) => {
15296                assert_eq!(err.signal, "comp-a");
15297                assert!(
15298                    err.reason
15299                        .contains("no boundary or generated component span"),
15300                    "reason: {}",
15301                    err.reason
15302                );
15303            }
15304            other => panic!("expected outside-boundary rejection, got {other:?}"),
15305        }
15306    }
15307
15308    fn parser_route_protocol(with_boundary: bool) -> Protocol {
15309        let mut parser = HtmlParser::with_options(DomStrategy::Light);
15310        parser
15311            .component_registry_mut()
15312            .register_component(ComponentRegistration::new(
15313                "route-page",
15314                "<p>route content</p>",
15315                None,
15316                true,
15317            ))
15318            .expect("register route component");
15319        let route = r#"<route path="/" component="route-page" exact />"#;
15320        let html = if with_boundary {
15321            format!(
15322                "<html><head></head><body>\
15323                 <boundary name=\"route\">{route}</boundary>\
15324                 </body></html>"
15325            )
15326        } else {
15327            format!("<html><head></head><body>{route}</body></html>")
15328        };
15329        parser
15330            .parse("index.html", &html)
15331            .expect("parse route streaming fixture");
15332        let mut document = WebUIProtocol::new(parser.into_fragment_records());
15333        document.components.insert(
15334            "route-page".to_string(),
15335            webui_protocol::ComponentData::default(),
15336        );
15337        Protocol::new(document)
15338    }
15339
15340    #[test]
15341    fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
15342        let protocol = parser_route_protocol(true);
15343        let mut writer = FlushTestWriter::default();
15344        WebUIHandler::new()
15345            .render_streaming(
15346                &protocol,
15347                &test_json!({}),
15348                &RenderOptions::new("index.html", "/"),
15349                &mut writer,
15350            )
15351            .unwrap();
15352
15353        assert!(
15354            writer
15355                .output
15356                .contains("<route-page data-ws><p>route content</p></route-page>"),
15357            "matched route host must be deferred before upgrade: {}",
15358            writer.output
15359        );
15360    }
15361
15362    #[test]
15363    fn streaming_parser_route_host_outside_boundary_is_rejected() {
15364        let protocol = parser_route_protocol(false);
15365        let mut writer = FlushTestWriter::default();
15366        let result = WebUIHandler::new().render_streaming(
15367            &protocol,
15368            &test_json!({}),
15369            &RenderOptions::new("index.html", "/"),
15370            &mut writer,
15371        );
15372
15373        match result {
15374            Err(HandlerError::StreamingBoundary(error)) => {
15375                assert_eq!(error.signal, "route-page");
15376                assert!(
15377                    error
15378                        .reason
15379                        .contains("no boundary or generated component span"),
15380                    "reason: {}",
15381                    error.reason
15382                );
15383            }
15384            other => panic!("expected route boundary rejection, got {other:?}"),
15385        }
15386        assert!(
15387            !writer.output.contains("<route-page>"),
15388            "an unmarked custom-element opening must never be completed"
15389        );
15390    }
15391
15392    #[test]
15393    fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
15394        let entry = vec![
15395            WebUIFragment::raw("<html><head>"),
15396            structural_fragment("head_start"),
15397            structural_fragment("head_end"),
15398            WebUIFragment::raw("</head><body>"),
15399            structural_fragment("body_start"),
15400            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15401            WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
15402                path: "/account".into(),
15403                fragment_id: "account-shell".into(),
15404                exact: false,
15405                ..Default::default()
15406            }),
15407            WebUIFragment::boundary_end(0),
15408            structural_fragment("body_end"),
15409            WebUIFragment::raw("</body></html>"),
15410        ];
15411        let fragments = HashMap::from([
15412            (
15413                "index.html".to_string(),
15414                FragmentList {
15415                    fragments: entry,
15416                    contains_boundary: true,
15417                },
15418            ),
15419            (
15420                "account-shell".to_string(),
15421                FragmentList {
15422                    fragments: vec![WebUIFragment::route_from(
15423                        webui_protocol::WebUiFragmentRoute {
15424                            path: "./details".into(),
15425                            fragment_id: "details-page".into(),
15426                            exact: true,
15427                            pending_component: "details-loading".into(),
15428                            error_component: "details-error".into(),
15429                            ..Default::default()
15430                        },
15431                    )],
15432                    contains_boundary: false,
15433                },
15434            ),
15435            (
15436                "details-page".to_string(),
15437                FragmentList {
15438                    fragments: vec![WebUIFragment::raw("<p>details</p>")],
15439                    contains_boundary: false,
15440                },
15441            ),
15442            (
15443                "details-loading".to_string(),
15444                FragmentList {
15445                    fragments: vec![WebUIFragment::raw("<p>loading</p>")],
15446                    contains_boundary: false,
15447                },
15448            ),
15449            (
15450                "details-error".to_string(),
15451                FragmentList {
15452                    fragments: vec![WebUIFragment::raw("<p>error</p>")],
15453                    contains_boundary: false,
15454                },
15455            ),
15456        ]);
15457        let mut document = WebUIProtocol::new(fragments);
15458        document.initial_state_strategy = InitialStateStrategy::Components as i32;
15459        for name in [
15460            "account-shell",
15461            "details-page",
15462            "details-loading",
15463            "details-error",
15464        ] {
15465            document.components.insert(
15466                name.to_string(),
15467                webui_protocol::ComponentData {
15468                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
15469                    ..Default::default()
15470                },
15471            );
15472        }
15473        let protocol = Protocol::new(document);
15474        let handler = WebUIHandler::with_plugin(|| {
15475            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15476        });
15477        let mut writer = FlushTestWriter::default();
15478
15479        handler
15480            .render_streaming(
15481                &protocol,
15482                &test_json!({}),
15483                &RenderOptions::new("index.html", "/account/details"),
15484                &mut writer,
15485            )
15486            .unwrap();
15487
15488        let first = &writer.output[writer.flushes[0]..writer.flushes[1]];
15489        assert!(
15490            first.contains(r#""details-loading":"#),
15491            "relative-route pending metadata missing: {first}"
15492        );
15493        assert!(
15494            first.contains(r#""details-error":"#),
15495            "relative-route error metadata missing: {first}"
15496        );
15497    }
15498
15499    #[test]
15500    fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
15501        let entry = vec![
15502            WebUIFragment::raw("<html><head>"),
15503            structural_fragment("head_start"),
15504            structural_fragment("head_end"),
15505            WebUIFragment::raw("</head><body>"),
15506            structural_fragment("body_start"),
15507            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15508            WebUIFragment::raw("<static-shell"),
15509            structural_fragment("streaming_root:static-shell"),
15510            WebUIFragment::raw(">"),
15511            WebUIFragment::component("static-shell"),
15512            WebUIFragment::raw("</static-shell>"),
15513            WebUIFragment::raw("<route-shell"),
15514            structural_fragment("streaming_root:route-shell"),
15515            WebUIFragment::raw(">"),
15516            WebUIFragment::component("route-shell"),
15517            WebUIFragment::raw("</route-shell>"),
15518            WebUIFragment::boundary_end(0),
15519            structural_fragment("body_end"),
15520            WebUIFragment::raw("</body></html>"),
15521        ];
15522        let fragments = HashMap::from([
15523            (
15524                "index.html".to_string(),
15525                FragmentList {
15526                    fragments: entry,
15527                    contains_boundary: true,
15528                },
15529            ),
15530            (
15531                "static-shell".to_string(),
15532                FragmentList {
15533                    fragments: vec![WebUIFragment::if_cond(
15534                        ConditionExpr::identifier("show_hidden"),
15535                        "static-hidden-if",
15536                    )],
15537                    contains_boundary: false,
15538                },
15539            ),
15540            (
15541                "static-hidden-if".to_string(),
15542                FragmentList {
15543                    fragments: vec![WebUIFragment::component("static-hidden")],
15544                    contains_boundary: false,
15545                },
15546            ),
15547            (
15548                "static-hidden".to_string(),
15549                FragmentList {
15550                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15551                    contains_boundary: false,
15552                },
15553            ),
15554            (
15555                "route-shell".to_string(),
15556                FragmentList {
15557                    fragments: vec![WebUIFragment::route_from(
15558                        webui_protocol::WebUiFragmentRoute {
15559                            path: "/account".into(),
15560                            fragment_id: "route-page".into(),
15561                            exact: true,
15562                            ..Default::default()
15563                        },
15564                    )],
15565                    contains_boundary: false,
15566                },
15567            ),
15568            (
15569                "route-page".to_string(),
15570                FragmentList {
15571                    fragments: vec![WebUIFragment::raw("<p>route</p>")],
15572                    contains_boundary: false,
15573                },
15574            ),
15575        ]);
15576        let mut document = WebUIProtocol::new(fragments);
15577        document.set_css_strategy(webui_protocol::CssStrategy::Link);
15578        document.initial_state_strategy = InitialStateStrategy::Components as i32;
15579        for (name, keys) in [
15580            ("static-shell", vec!["show_hidden", "static_count"]),
15581            ("static-hidden", vec!["hidden_count"]),
15582            ("route-shell", vec!["route_shell_count"]),
15583            ("route-page", vec!["route_count"]),
15584        ] {
15585            document.components.insert(
15586                name.to_string(),
15587                webui_protocol::ComponentData {
15588                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
15589                    hydration_mode: StateProjectionMode::Keys as i32,
15590                    hydration_keys: keys.into_iter().map(str::to_string).collect(),
15591                    css_href: format!("/{name}.css"),
15592                    ..Default::default()
15593                },
15594            );
15595        }
15596        document.populate_style_closures(&["index.html"]);
15597        let protocol = Protocol::new(document);
15598        assert_eq!(
15599            protocol
15600                .component_reachability()
15601                .is_route_dependent(protocol.component_index()["route-shell"]),
15602            Some(true),
15603            "fixture must exercise request-aware checkpoint reachability"
15604        );
15605        let handler = WebUIHandler::with_plugin(|| {
15606            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15607        });
15608        let mut writer = FlushTestWriter::default();
15609
15610        handler
15611            .render_streaming(
15612                &protocol,
15613                &test_json!({
15614                    "show_hidden": false,
15615                    "static_count": 1,
15616                    "hidden_count": 2,
15617                    "route_shell_count": 3,
15618                    "route_count": 4,
15619                    "unrelated": "private",
15620                }),
15621                &RenderOptions::new("index.html", "/account"),
15622                &mut writer,
15623            )
15624            .unwrap();
15625
15626        let checkpoint = &writer.output[writer.flushes[0]..writer.flushes[1]];
15627        for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
15628            assert!(
15629                checkpoint.contains(&format!(r#""{template}":"#)),
15630                "{template} metadata missing: {checkpoint}"
15631            );
15632        }
15633        for state in [
15634            r#""show_hidden":false"#,
15635            r#""static_count":1"#,
15636            r#""hidden_count":2"#,
15637            r#""route_shell_count":3"#,
15638            r#""route_count":4"#,
15639        ] {
15640            assert!(
15641                checkpoint.contains(state),
15642                "{state} state missing: {checkpoint}"
15643            );
15644        }
15645        assert!(
15646            !checkpoint.contains("unrelated"),
15647            "checkpoint leaked unrelated state: {checkpoint}"
15648        );
15649        assert!(
15650            checkpoint.contains(
15651                r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
15652            ),
15653            "checkpoint metadata lost source order: {checkpoint}"
15654        );
15655        assert!(
15656            checkpoint.contains(r#""componentStyles":"#)
15657                && checkpoint.contains(r#""strategy":"link""#)
15658                && checkpoint.contains(r#""static-shell":["static-shell","static-hidden"]"#),
15659            "checkpoint is missing tree-local style metadata: {checkpoint}"
15660        );
15661        for resource in ["static-shell", "static-hidden", "route-shell", "route-page"] {
15662            assert_eq!(
15663                writer
15664                    .output
15665                    .matches(&format!(r#"data-webui-resource="{resource}""#))
15666                    .count(),
15667                1,
15668                "Document delivery state must persist across streamed checkpoints"
15669            );
15670        }
15671    }
15672
15673    #[test]
15674    fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
15675        // Three boundaries: comp-a, comp-b, then comp-a reused. Each checkpoint
15676        // envelope must be locally scoped — templates/state for exactly the
15677        // components rendered since the previous checkpoint, with reused tags
15678        // carrying hydration state but never a duplicate template.
15679        let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15680        let handler = WebUIHandler::with_plugin(|| {
15681            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15682        });
15683        let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
15684        let mut writer = FlushTestWriter::default();
15685        handler
15686            .render_streaming(
15687                &protocol,
15688                &state,
15689                &RenderOptions::new("index.html", "/"),
15690                &mut writer,
15691            )
15692            .unwrap();
15693
15694        assert_eq!(
15695            writer.flushes.len(),
15696            5,
15697            "bootstrap, three boundary commits, and one terminal"
15698        );
15699        let segment = |index: usize| -> &str {
15700            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15701        };
15702        let b0 = segment(0);
15703        let b1 = segment(1);
15704        let b2 = segment(2);
15705
15706        // Boundary 0: only comp-a's template/state; no comp-b artifacts.
15707        assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
15708        assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
15709        assert!(
15710            b0.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
15711            "b0: {b0}"
15712        );
15713        assert!(b0.contains(r#""inventory":"01""#), "b0: {b0}");
15714        assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
15715        assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
15716
15717        // Boundary 1: only comp-b's template/state; no duplicate comp-a template.
15718        assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
15719        assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
15720        assert!(
15721            b1.contains(r#"[1,0,1,{"declarationId":1,"componentStyles":"#),
15722            "b1: {b1}"
15723        );
15724        assert!(b1.contains(r#""inventory":"02""#), "b1: {b1}");
15725        assert!(
15726            !b1.contains(r#""templates":{"comp-a"#),
15727            "b1 re-sent comp-a: {b1}"
15728        );
15729        assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
15730
15731        // Boundary 2: comp-a reused — state present, template absent (empty delta).
15732        assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
15733        assert!(
15734            b2.contains(r#"[2,0,2,{"declarationId":2,"componentStyles":"#),
15735            "b2: {b2}"
15736        );
15737        assert!(b2.contains(r#""inventory":"""#), "b2: {b2}");
15738        assert!(
15739            !b2.contains(r#""templates""#),
15740            "b2 re-sent a template: {b2}"
15741        );
15742
15743        // Server-only state never leaks into any envelope.
15744        assert!(!writer.output.contains("serverOnly"));
15745    }
15746
15747    #[test]
15748    fn streaming_checkpoints_emit_style_resources_and_closures_once() {
15749        let protocol = styled_disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15750        let handler = WebUIHandler::with_plugin(|| {
15751            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15752        });
15753        let mut writer = FlushTestWriter::default();
15754        handler
15755            .render_streaming(
15756                &protocol,
15757                &test_json!({ "a_count": 1, "b_count": 2 }),
15758                &RenderOptions::new("index.html", "/"),
15759                &mut writer,
15760            )
15761            .unwrap();
15762
15763        let segment = |index: usize| -> &str {
15764            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15765        };
15766        let checkpoint_styles = |checkpoint: &str| -> Value {
15767            let script = checkpoint
15768                .rfind(r#"<script type="application/json" data-webui-boundary"#)
15769                .unwrap();
15770            let payload_start = script + checkpoint[script..].find('>').unwrap() + 1;
15771            let payload_end =
15772                payload_start + checkpoint[payload_start..].find("</script>").unwrap();
15773            let record: Value =
15774                serde_json::from_str(&checkpoint[payload_start..payload_end]).unwrap();
15775            record[3]["componentStyles"].clone()
15776        };
15777
15778        let first = checkpoint_styles(segment(0));
15779        assert!(first["resources"]["comp-a"].is_object());
15780        assert!(first["resources"]["comp-b"].is_object());
15781        assert!(first["closures"]["index.html"].is_array());
15782        assert!(first["closures"]["comp-a"].is_array());
15783
15784        let second = checkpoint_styles(segment(1));
15785        assert_eq!(second["resources"], test_json!({}));
15786        assert_eq!(second["closures"], test_json!({ "comp-b": ["comp-b"] }));
15787
15788        let repeated = checkpoint_styles(segment(2));
15789        assert_eq!(repeated["resources"], test_json!({}));
15790        assert_eq!(repeated["closures"], test_json!({}));
15791        for resource in ["comp-a", "comp-b"] {
15792            assert_eq!(
15793                [&first, &second, &repeated]
15794                    .iter()
15795                    .filter(|styles| {
15796                        styles["resources"]
15797                            .as_object()
15798                            .is_some_and(|resources| resources.contains_key(resource))
15799                    })
15800                    .count(),
15801                1,
15802                "{resource} style metadata was serialized more than once"
15803            );
15804        }
15805    }
15806
15807    #[test]
15808    fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
15809        let entry = vec![
15810            WebUIFragment::raw("<html><head>"),
15811            structural_fragment("head_start"),
15812            structural_fragment("head_end"),
15813            WebUIFragment::raw("</head><body>"),
15814            structural_fragment("body_start"),
15815            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15816            WebUIFragment::raw("<comp-a"),
15817            structural_fragment("streaming_root:comp-a"),
15818            WebUIFragment::raw(">"),
15819            WebUIFragment::component("comp-a"),
15820            WebUIFragment::raw("</comp-a>"),
15821            WebUIFragment::boundary_end(0),
15822            WebUIFragment::boundary(1, "index.html", "boundary-1", None),
15823            WebUIFragment::raw("<comp-hidden"),
15824            structural_fragment("streaming_root:comp-hidden"),
15825            WebUIFragment::raw(">"),
15826            WebUIFragment::component("comp-hidden"),
15827            WebUIFragment::raw("</comp-hidden>"),
15828            WebUIFragment::boundary_end(1),
15829            structural_fragment("body_end"),
15830            WebUIFragment::raw("</body></html>"),
15831        ];
15832        let fragments = HashMap::from([
15833            (
15834                "index.html".to_string(),
15835                FragmentList {
15836                    fragments: entry,
15837                    contains_boundary: true,
15838                },
15839            ),
15840            (
15841                "comp-a".to_string(),
15842                FragmentList {
15843                    fragments: vec![WebUIFragment::if_cond(
15844                        ConditionExpr::identifier("show_hidden"),
15845                        "hidden-if",
15846                    )],
15847                    contains_boundary: false,
15848                },
15849            ),
15850            (
15851                "hidden-if".to_string(),
15852                FragmentList {
15853                    fragments: vec![WebUIFragment::component("comp-hidden")],
15854                    contains_boundary: false,
15855                },
15856            ),
15857            (
15858                "comp-hidden".to_string(),
15859                FragmentList {
15860                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15861                    contains_boundary: false,
15862                },
15863            ),
15864        ]);
15865        let mut document = WebUIProtocol::new(fragments);
15866        document.set_css_strategy(webui_protocol::CssStrategy::Module);
15867        document.initial_state_strategy = InitialStateStrategy::Components as i32;
15868        document.components.insert(
15869            "comp-a".to_string(),
15870            webui_protocol::ComponentData {
15871                template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
15872                hydration_mode: StateProjectionMode::Keys as i32,
15873                hydration_keys: vec!["show_hidden".to_string()],
15874                ..Default::default()
15875            },
15876        );
15877        document.components.insert(
15878            "comp-hidden".to_string(),
15879            webui_protocol::ComponentData {
15880                template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
15881                css: ".hidden{display:block}".to_string(),
15882                hydration_mode: StateProjectionMode::Keys as i32,
15883                hydration_keys: vec!["hidden_count".to_string()],
15884                ..Default::default()
15885            },
15886        );
15887        document.populate_style_closures(&["index.html"]);
15888        let protocol = Protocol::new(document);
15889        let handler = WebUIHandler::with_plugin(|| {
15890            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15891        });
15892        let mut writer = FlushTestWriter::default();
15893
15894        handler
15895            .render_streaming(
15896                &protocol,
15897                &test_json!({
15898                    "show_hidden": false,
15899                    "hidden_count": 7,
15900                    "unrelated": "private",
15901                }),
15902                &RenderOptions::new("index.html", "/"),
15903                &mut writer,
15904            )
15905            .unwrap();
15906
15907        let boundary = |index: usize| -> &str {
15908            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15909        };
15910        let first = boundary(0);
15911        let second = boundary(1);
15912
15913        assert!(first.contains(r#""comp-a":"#), "first: {first}");
15914        assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
15915        assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
15916        assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
15917        assert!(first.contains(r#""inventory":"01""#), "first: {first}");
15918        assert!(
15919            first.contains(r#"<script type="importmap""#),
15920            "first: {first}"
15921        );
15922        assert!(
15923            !first.contains("unrelated"),
15924            "first leaked unrelated state: {first}"
15925        );
15926
15927        assert!(
15928            !second.contains(r#""templates""#),
15929            "second re-sent metadata: {second}"
15930        );
15931        assert!(
15932            !second.contains(r#"<script type="importmap""#),
15933            "second re-sent CSS: {second}"
15934        );
15935        assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
15936        assert!(second.contains(r#""inventory":"02""#), "second: {second}");
15937        assert_eq!(
15938            writer
15939                .output
15940                .matches(r#"<script type="importmap">"#)
15941                .count(),
15942            1,
15943            "import-map output: {}",
15944            writer.output
15945        );
15946    }
15947
15948    #[test]
15949    fn streaming_flush_count_is_boundaries_plus_one() {
15950        for &count in &[1usize, 3, 10, 100] {
15951            let hosts: Vec<&str> = (0..count)
15952                .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
15953                .collect();
15954            let protocol = disjoint_streaming_protocol(&hosts);
15955            let handler = WebUIHandler::with_plugin(|| {
15956                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15957            });
15958            let mut writer = FlushTestWriter::default();
15959            handler
15960                .render_streaming(
15961                    &protocol,
15962                    &test_json!({ "a_count": 1, "b_count": 2 }),
15963                    &RenderOptions::new("index.html", "/"),
15964                    &mut writer,
15965                )
15966                .unwrap();
15967            assert_eq!(
15968                writer.flushes.len(),
15969                count + 2,
15970                "expected bootstrap + boundaries + terminal flushes for {count} boundaries"
15971            );
15972        }
15973    }
15974
15975    #[test]
15976    fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
15977        let mut fragments = HashMap::new();
15978        fragments.insert("comp-a".to_string(), FragmentList::default());
15979        fragments.insert("comp-b".to_string(), FragmentList::default());
15980        let mut protocol = WebUIProtocol::new(fragments);
15981        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
15982        protocol.components.insert(
15983            "comp-a".to_string(),
15984            webui_protocol::ComponentData {
15985                hydration_mode: StateProjectionMode::Keys as i32,
15986                hydration_keys: vec!["a".to_string(), "shared".to_string()],
15987                ..Default::default()
15988            },
15989        );
15990        protocol.components.insert(
15991            "comp-b".to_string(),
15992            webui_protocol::ComponentData {
15993                hydration_mode: StateProjectionMode::Keys as i32,
15994                hydration_keys: vec!["b".to_string(), "shared".to_string()],
15995                ..Default::default()
15996            },
15997        );
15998
15999        let protocol = Protocol::new(protocol);
16000        let component_index = protocol.component_index();
16001        let reachability = protocol.component_reachability();
16002        let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
16003        assert!(!collect_hydration_key_ids_into(
16004            protocol.protocol(),
16005            reachability,
16006            [component_index["comp-a"]],
16007            &mut scratch,
16008        ));
16009        let keys = scratch
16010            .iter()
16011            .filter_map(|id| reachability.hydration_key(*id))
16012            .collect::<Vec<_>>();
16013        assert_eq!(keys, ["a", "shared"]);
16014        let pointer = scratch.as_ptr();
16015        let capacity = scratch.capacity();
16016
16017        assert!(!collect_hydration_key_ids_into(
16018            protocol.protocol(),
16019            reachability,
16020            [component_index["comp-b"]],
16021            &mut scratch,
16022        ));
16023        let keys = scratch
16024            .iter()
16025            .filter_map(|id| reachability.hydration_key(*id))
16026            .collect::<Vec<_>>();
16027        assert_eq!(keys, ["b", "shared"]);
16028        assert_eq!(scratch.as_ptr(), pointer);
16029        assert_eq!(scratch.capacity(), capacity);
16030    }
16031}