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 response_writer;
13pub mod route_handler;
14pub mod route_matcher;
15pub(crate) mod route_renderer;
16pub(crate) mod streaming;
17
18#[doc(hidden)]
19pub use response_writer::{
20    append_attribute_to_bytes, append_attribute_to_string, append_boolean_attribute_to_bytes,
21    append_boolean_attribute_to_string,
22};
23pub use route_handler::Protocol;
24
25/// Minimal HTML escaper for the 6 XSS-critical characters
26/// (`& < > " ' /`). Returns `Cow::Borrowed` when no escaping is
27/// needed (zero allocation on the happy path), `Cow::Owned` when
28/// any character had to be replaced.
29///
30/// Re-exported here so external callers of `RenderOptions::with_head_inject`
31/// / `with_body_inject` can pre-escape untrusted content with the
32/// same escaper the handler uses internally for SSR text content,
33/// without having to pull in a separate HTML-escape crate.
34pub use html_encode::encode_safe;
35
36use plugin::BootstrapExtensionContext;
37use plugin::HandlerPlugin;
38use plugin::WebUiTemplatePayload;
39use route_handler::ComponentReachabilityIndex;
40use route_matcher::CompiledRouteIndex;
41use serde::ser::SerializeMap;
42use serde::Serialize;
43use serde_json::Value;
44use std::borrow::Cow;
45use std::cell::{Cell, OnceCell};
46use std::collections::{HashMap, HashSet};
47use std::fmt::{self, Write as _};
48use std::sync::Arc;
49use streaming::{
50    consume_streaming_component_root, ensure_no_pending_streaming_root,
51    prepare_generated_streaming_root, record_checkpoint_tag, streaming_template_already_sent,
52    validate_pending_streaming_root, validate_streaming_root_opening, ComponentHostOrigin,
53    StreamingRenderState,
54};
55pub use streaming::{
56    BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, BufferSink, SessionOptions,
57    SpanInstanceId, StreamStatus, StreamStep, StreamingResponse, StreamingSession, StreamingState,
58    MAX_BOUNDARY_OCCURRENCES, MAX_CONTINUATION_DEPTH, MAX_KEYED_INSTANCES, MAX_SPAN_NESTING,
59};
60use thiserror::Error;
61use webui_expressions::{evaluate_with_resolver, ExpressionError};
62use webui_protocol::{
63    web_ui_fragment::Fragment, ComponentAssetStylePreload, ComponentWorkPolicy, FragmentList,
64    InitialStateStrategy, StateProjectionMode, WebUIFragment, WebUIProtocol,
65};
66use webui_state::find_value_by_dotted_path_ref;
67
68/// Error types for the WebUI handler.
69#[derive(Debug, Error)]
70pub enum HandlerError {
71    #[error("Rendering error: {0}")]
72    Rendering(String),
73
74    /// Host-supplied state JSON could not be parsed or validated.
75    ///
76    /// This is a caller input error, not a render failure. Bindings map it to
77    /// their own state-error type, so it must stay distinguishable from
78    /// [`HandlerError::Rendering`] without inspecting the message text.
79    #[error("invalid state JSON: {0}")]
80    InvalidState(String),
81
82    #[error("Rendering invariant error: {0}")]
83    Invariant(String),
84
85    #[error("Missing fragment: {0}")]
86    MissingFragment(String),
87
88    #[error("Missing data field: {0}")]
89    MissingData(String),
90
91    #[error("Type error: {0}")]
92    TypeError(String),
93
94    #[error("Protocol error: {0}")]
95    Protocol(#[from] webui_protocol::ProtocolError),
96
97    #[error("Evaluation error: {0}")]
98    Evaluation(String),
99
100    #[error("I/O error: {0}")]
101    Io(#[from] std::io::Error),
102
103    #[error("Writer error: {0}")]
104    Writer(String),
105
106    #[error("Plugin data error: {0}")]
107    PluginData(String),
108
109    /// The HTTP client disconnected before the render completed.
110    ///
111    /// Streaming `ResponseWriter` implementations return this from
112    /// `write()` once their channel/socket is closed, so the handler
113    /// can abort the render rather than do CPU work that has nowhere
114    /// to go. Allocation-free (the variant carries no payload).
115    #[error("client disconnected")]
116    ClientDisconnected,
117
118    /// The streaming writer's flush exceeded its configured deadline.
119    ///
120    /// Indicates a slow/unresponsive consumer (slow-loris client,
121    /// stuck proxy, etc.). The render thread is freed; downstream
122    /// telemetry should distinguish this from `ClientDisconnected`
123    /// so ops can alert on slow-client attacks.
124    #[error("streaming flush timed out")]
125    StreamTimeout,
126
127    /// A streaming-boundary signal was malformed or arrived out of order.
128    ///
129    /// The payload is boxed so this cold, streaming-only variant does not
130    /// widen [`HandlerError`] — and therefore `Result<(), HandlerError>`
131    /// threaded through the entire hot legacy render path — with two inline
132    /// `String`s.
133    #[error("invalid streaming boundary signal `{}`: {}", .0.signal, .0.reason)]
134    StreamingBoundary(Box<StreamingBoundaryError>),
135
136    /// A streaming render ended without its structural `body_end` signal.
137    #[error("streaming render ended before `body_end`; no terminal boundary record was emitted")]
138    MissingStreamingBodyEnd,
139
140    /// Streaming initialization cannot be emitted before document content.
141    #[error(
142        "streaming protocol is missing the required `head_start` signal before `{before}`; \
143         rebuild the protocol with streaming-boundary parser support"
144    )]
145    MissingStreamingHeadStart {
146        /// Structural point that made initialization too late.
147        before: &'static str,
148    },
149
150    /// A malformed protocol emitted streaming initialization more than once.
151    #[error("streaming protocol emitted duplicate `head_start` signals")]
152    DuplicateStreamingHeadStart,
153}
154
155/// Boxed payload for [`HandlerError::StreamingBoundary`].
156///
157/// Kept behind a `Box` so the streaming-validation variant contributes only a
158/// pointer-sized payload to [`HandlerError`], keeping the common
159/// `Result<(), HandlerError>` small on the ordinary render path where these
160/// boundary errors never occur.
161#[derive(Debug)]
162pub struct StreamingBoundaryError {
163    /// The logical structural token (the internal namespace is stripped).
164    pub signal: String,
165    /// Actionable validation failure.
166    pub reason: String,
167}
168
169pub type Result<T> = std::result::Result<T, HandlerError>;
170
171#[cold]
172#[inline(never)]
173fn invalid_fragment_range_error(
174    range: &std::ops::Range<usize>,
175    fragment_count: usize,
176) -> HandlerError {
177    HandlerError::Invariant(format!(
178        "fragment range {}..{} exceeds fragment count {fragment_count}",
179        range.start, range.end
180    ))
181}
182
183#[cold]
184#[inline(never)]
185fn route_style_plan_missing_error() -> HandlerError {
186    HandlerError::Invariant(
187        "matched route style targets were computed without a route chain".to_string(),
188    )
189}
190
191#[cold]
192#[inline(never)]
193fn route_style_plan_length_error(routes: usize, targets: usize) -> HandlerError {
194    HandlerError::Invariant(format!(
195        "matched route style target count {targets} does not match route count {routes}"
196    ))
197}
198
199/// Interface for writing rendered output
200pub trait ResponseWriter {
201    /// Write content to the output
202    fn write(&mut self, content: &str) -> Result<()>;
203
204    /// Write one complete quoted HTML attribute.
205    #[doc(hidden)]
206    fn write_attribute(&mut self, name: &str, value: &str) -> Result<()> {
207        self.write(" ")?;
208        self.write(name)?;
209        self.write("=\"")?;
210        self.write(value)?;
211        self.write("\"")
212    }
213
214    /// Write one complete boolean HTML attribute.
215    #[doc(hidden)]
216    fn write_boolean_attribute(&mut self, name: &str) -> Result<()> {
217        self.write(" ")?;
218        self.write(name)
219    }
220
221    /// Finalize the output
222    fn end(&mut self) -> Result<()>;
223
224    /// Hand buffered bytes to the transport at a committed streaming boundary.
225    ///
226    /// Internal hook used only by the progressive streaming render path so the
227    /// request-local sink can flush its concrete transport without a shared-cell
228    /// borrow or a second virtual dispatch on every write. Ordinary writers keep
229    /// the no-op default; hosts must not override or depend on this — implement
230    /// [`FlushWriter`] and use [`WebUIHandler::render_streaming`] instead.
231    #[doc(hidden)]
232    fn stream_flush(&mut self) -> Result<()> {
233        Ok(())
234    }
235
236    #[doc(hidden)]
237    fn stream_begin_component(&mut self) -> Result<()> {
238        Err(HandlerError::Invariant(
239            "component opening buffering requires a streaming sink".to_string(),
240        ))
241    }
242
243    #[doc(hidden)]
244    fn stream_mark_component_root(&mut self) -> Result<()> {
245        Err(HandlerError::Invariant(
246            "component root buffering requires a streaming sink".to_string(),
247        ))
248    }
249
250    #[doc(hidden)]
251    fn stream_commit_component(
252        &mut self,
253        span_id: Option<u32>,
254        enclosing_span_id: Option<u32>,
255        deferred: bool,
256    ) -> Result<()> {
257        let _ = (span_id, enclosing_span_id, deferred);
258        Err(HandlerError::Invariant(
259            "component opening commit requires a streaming sink".to_string(),
260        ))
261    }
262}
263
264/// Serialize every application state key except a sorted base projection.
265///
266/// Streaming uses this only when a prior keyed projection is a proven subset
267/// of the current full-state projection. The browser merges the emitted delta
268/// over that exact prior range state.
269struct StateWithoutSelectedKeys<'a> {
270    value: &'a Value,
271    keys: KeyView<'a>,
272}
273
274impl Serialize for StateWithoutSelectedKeys<'_> {
275    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
276    where
277        S: serde::Serializer,
278    {
279        let Value::Object(map) = self.value else {
280            return serializer.serialize_map(Some(0))?.end();
281        };
282        let mut out = serializer.serialize_map(None)?;
283        for (key, value) in map {
284            if key != STATE_INJECT_KEY && !self.keys.contains(key) {
285                out.serialize_entry(key, value)?;
286            }
287        }
288        out.end()
289    }
290}
291
292/// A response writer that can hand buffered bytes to its transport immediately.
293///
294/// Progressive hydration requires this semantic flush at every committed
295/// boundary. Hosts that cannot provide it must use [`WebUIHandler::render`].
296pub trait FlushWriter: ResponseWriter {
297    /// Hand all currently buffered bytes to the underlying transport.
298    fn flush(&mut self) -> Result<()>;
299}
300
301/// Options controlling how the handler renders a protocol.
302///
303/// The handler performs server-side route matching: matched routes are rendered
304/// visible with content; non-matched routes are rendered hidden and empty.
305pub struct RenderOptions<'a> {
306    /// The fragment ID to start rendering from (e.g., `"index.html"`).
307    pub entry_id: &'a str,
308    /// The URL path to match routes against (e.g., `"/contacts/42"`).
309    pub request_path: &'a str,
310    /// Optional CSP nonce for inline `<script>` tags.
311    /// When set, all inline scripts include `nonce="VALUE"` and a
312    /// `<meta name="webui-nonce">` tag is emitted for the client router.
313    pub nonce: Option<&'a str>,
314    /// Optional HTML to emit immediately before the document's
315    /// `</head>` close. Used for per-request `<link rel="preload">`
316    /// hints, CSP `<meta>` tags beyond the built-in nonce, etc.
317    /// Inserted at the structural `head_end` boundary identified by
318    /// the parser — never matched against a byte pattern, so cannot
319    /// be tricked by `</head>` literals appearing in HTML comments,
320    /// `srcdoc` attributes, or inline scripts.
321    pub head_inject: Option<&'a str>,
322    /// Optional HTML to emit immediately before the document's
323    /// `</body>` close. Used for dev livereload `<script>`, analytics
324    /// snippets, OpenTelemetry trace IDs, etc.
325    /// Same structural-boundary guarantee as [`head_inject`](Self::head_inject).
326    pub body_inject: Option<&'a str>,
327}
328
329/// Reserved top-level state key carrying host-supplied boundary HTML.
330///
331/// The leading `$` marks this key as reserved host metadata rather than
332/// ordinary application state.
333///
334/// Recognized members, each an optional string:
335///
336/// | Member | Emitted at |
337/// | --- | --- |
338/// | `headEnd` | immediately before `</head>` |
339/// | `bodyStart` | immediately after `<body>` |
340/// | `bodyEnd` | immediately before `</body>` |
341///
342/// The key is always honored — it is part of the render state the host
343/// already supplies — and is stripped from the client hydration payload.
344pub const STATE_INJECT_KEY: &str = "$webui";
345
346/// Host-supplied boundary HTML resolved once per render from
347/// [`STATE_INJECT_KEY`].
348///
349/// Resolution happens when the render context is built, so each structural
350/// hook costs one `Option` check instead of a state map lookup. Every field
351/// borrows from the caller's state value — no clone, no allocation.
352#[derive(Clone, Copy, Default)]
353pub(crate) struct StateInject<'state> {
354    pub(crate) head_end: Option<&'state str>,
355    pub(crate) body_start: Option<&'state str>,
356    pub(crate) body_end: Option<&'state str>,
357}
358
359impl<'state> StateInject<'state> {
360    /// Resolve the reserved namespace from a render state value.
361    ///
362    /// Returns the empty set when the key is absent, or when any member is
363    /// missing, null, empty, or not a string. Malformed input is inert rather than an error: the reserved
364    /// key is an optional side channel, and a render must not fail because a
365    /// host wrote the wrong shape into it.
366    pub(crate) fn resolve(state: &'state Value) -> Self {
367        let Some(Value::Object(map)) = state.get(STATE_INJECT_KEY) else {
368            return Self::default();
369        };
370        let field = |name: &str| {
371            map.get(name)
372                .and_then(Value::as_str)
373                .filter(|html| !html.is_empty())
374        };
375        Self {
376            head_end: field("headEnd"),
377            body_start: field("bodyStart"),
378            body_end: field("bodyEnd"),
379        }
380    }
381}
382
383impl<'a> RenderOptions<'a> {
384    /// Create render options for the given entry fragment and request path.
385    #[must_use]
386    pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
387        Self {
388            entry_id,
389            request_path,
390            nonce: None,
391            head_inject: None,
392            body_inject: None,
393        }
394    }
395
396    /// Set the CSP nonce for inline scripts. Pass an empty string to
397    /// disable (`None` semantics) — empty `<meta name="webui-nonce"
398    /// content="">` would be browser-ignored noise.
399    #[must_use]
400    pub fn with_nonce(mut self, nonce: &'a str) -> Self {
401        self.nonce = if nonce.is_empty() { None } else { Some(nonce) };
402        self
403    }
404
405    /// Set HTML to emit immediately before `</head>`.
406    /// Pass an empty string to disable (`None` semantics).
407    ///
408    /// # Safety (XSS warning)
409    ///
410    /// The provided HTML is written verbatim — **no HTML escaping is
411    /// performed**. Callers MUST ensure the content is fully trusted
412    /// (typically a `&'static str` or build-time-derived bytes such as
413    /// dev livereload script, image preload `<link>` tags, or A/B test
414    /// markers). Passing user-controlled or attacker-influenced content
415    /// here is a direct cross-site scripting vulnerability. If your
416    /// caller path may include untrusted data, escape with the host's
417    /// HTML escaper (e.g. [`webui_handler::encode_safe`](crate::encode_safe))
418    /// **before** calling this builder.
419    #[must_use]
420    pub fn with_head_inject(mut self, html: &'a str) -> Self {
421        self.head_inject = if html.is_empty() { None } else { Some(html) };
422        self
423    }
424
425    /// Set HTML to emit immediately before `</body>`.
426    /// Pass an empty string to disable (`None` semantics).
427    ///
428    /// # Safety (XSS warning)
429    ///
430    /// Same contract as [`with_head_inject`](Self::with_head_inject):
431    /// the HTML is written verbatim with **no escaping**, so callers
432    /// MUST ensure the content is fully trusted. Untrusted content is
433    /// a direct XSS vector.
434    #[must_use]
435    pub fn with_body_inject(mut self, html: &'a str) -> Self {
436        self.body_inject = if html.is_empty() { None } else { Some(html) };
437        self
438    }
439}
440
441/// The main WebUI handler that processes protocols and renders them.
442///
443/// The handler is stateless: plugin instances are created per-render from
444/// the stored factory function, allowing concurrent renders with `&self`.
445pub struct WebUIHandler {
446    plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
447}
448
449#[derive(Clone, Copy, Eq, PartialEq)]
450enum StyleClosureInstall {
451    Static,
452    Routed,
453}
454
455pub(crate) struct ShadowStyleRoot {
456    component_index: u32,
457    static_closure_emitted: bool,
458    routed_resources: Vec<u32>,
459}
460
461struct LoopBinding<'protocol, 'state> {
462    name: &'protocol str,
463    value: &'state Value,
464}
465
466#[derive(Clone, Copy)]
467struct VisibleLoopScope {
468    start: usize,
469    end: usize,
470}
471
472impl VisibleLoopScope {
473    const EMPTY: Self = Self { start: 0, end: 0 };
474}
475
476#[derive(Clone, Copy)]
477struct LocalValueSources<'ctx, 'protocol, 'state> {
478    owned: &'ctx HashMap<String, Value>,
479    borrowed: &'ctx BorrowedScope<'protocol, 'state>,
480}
481
482/// Route level an `<outlet />` matches against.
483pub(crate) type RouteChildren<'protocol> = Cow<'protocol, [webui_protocol::WebUiFragmentRoute]>;
484
485#[derive(Default)]
486struct BorrowedScope<'protocol, 'state> {
487    inline: [Option<(&'protocol str, &'state Value)>; INLINE_SCOPE_SLOTS],
488    inline_len: usize,
489    overflow: Vec<(&'protocol str, &'state Value)>,
490}
491
492impl<'protocol, 'state> BorrowedScope<'protocol, 'state> {
493    fn get(&self, name: &str) -> Option<&'state Value> {
494        if self.inline_len == 0 {
495            return None;
496        }
497        if let Some((entry_name, value)) = self.inline[0].as_ref() {
498            if *entry_name == name {
499                return Some(*value);
500            }
501        }
502        for (entry_name, value) in self.inline[1..self.inline_len].iter().flatten() {
503            if *entry_name == name {
504                return Some(*value);
505            }
506        }
507        self.overflow
508            .iter()
509            .find_map(|(entry_name, value)| (*entry_name == name).then_some(*value))
510    }
511
512    fn insert(&mut self, name: &'protocol str, value: &'state Value) -> Option<&'state Value> {
513        for (entry_name, current) in self.inline[..self.inline_len].iter_mut().flatten() {
514            if *entry_name == name {
515                return Some(std::mem::replace(current, value));
516            }
517        }
518        for (entry_name, current) in &mut self.overflow {
519            if *entry_name == name {
520                return Some(std::mem::replace(current, value));
521            }
522        }
523        if self.inline_len < INLINE_SCOPE_SLOTS {
524            self.inline[self.inline_len] = Some((name, value));
525            self.inline_len += 1;
526        } else {
527            self.overflow.push((name, value));
528        }
529        None
530    }
531
532    fn remove(&mut self, name: &str) -> Option<&'state Value> {
533        if let Some(index) = self.inline[..self.inline_len].iter().position(|entry| {
534            entry
535                .as_ref()
536                .is_some_and(|(entry_name, _)| *entry_name == name)
537        }) {
538            let removed = self.inline[index].take().map(|(_, value)| value);
539            if let Some(entry) = self.overflow.pop() {
540                self.inline[index] = Some(entry);
541            } else {
542                self.inline_len -= 1;
543                self.inline[index] = self.inline[self.inline_len].take();
544            }
545            return removed;
546        }
547        let index = self
548            .overflow
549            .iter()
550            .position(|(entry_name, _)| *entry_name == name)?;
551        Some(self.overflow.swap_remove(index).1)
552    }
553
554    fn clear(&mut self) {
555        for entry in &mut self.inline[..self.inline_len] {
556            *entry = None;
557        }
558        self.inline_len = 0;
559        self.overflow.clear();
560    }
561
562    fn clone_into_owned(&self, target: &mut HashMap<String, Value>) {
563        for (name, value) in self.inline[..self.inline_len].iter().flatten() {
564            target.insert((*name).to_owned(), (*value).clone());
565        }
566        for (name, value) in &self.overflow {
567            target.insert((*name).to_owned(), (*value).clone());
568        }
569    }
570}
571
572/// A fragment list paired with the render metadata prepared for it when the
573/// runtime [`Protocol`] was loaded.
574///
575/// `fragments` still points at the protocol's own storage, so passing this by
576/// value never copies or clones a fragment graph.
577#[derive(Clone, Copy)]
578pub(crate) struct RenderFragmentList<'protocol> {
579    fragments: &'protocol [WebUIFragment],
580    metadata: &'protocol [RenderFragmentMetadata],
581    attr_names: &'protocol str,
582    contains_boundary: bool,
583    /// True when this list contains at least one `<webui-route>` fragment.
584    /// Renders skip the sibling route pre-scan entirely when it is false.
585    has_routes: bool,
586}
587
588impl<'protocol> RenderFragmentList<'protocol> {
589    /// Render slot the fragment at `index` descends into, if any.
590    fn target(self, index: usize) -> Option<usize> {
591        let target = self.metadata.get(index)?.target;
592        (target != NO_RENDER_SLOT).then_some(target as usize)
593    }
594
595    /// Canonical camelCase component prop name prepared for an attribute fragment.
596    fn component_attr_name(self, index: usize) -> Option<&'protocol str> {
597        let prepared = self.metadata.get(index)?;
598        if prepared.attr_start == NO_ATTR_NAME {
599            return None;
600        }
601        let start = prepared.attr_start as usize;
602        self.attr_names
603            .get(start..start + prepared.attr_len as usize)
604    }
605}
606
607/// Sentinel for "this fragment does not descend into another fragment list".
608const NO_RENDER_SLOT: u32 = u32::MAX;
609/// Sentinel for "this fragment is not an attribute fragment".
610const NO_ATTR_NAME: u32 = u32::MAX;
611
612/// Per-fragment values hoisted out of the render loop at protocol load time.
613///
614/// Deliberately a flat 12-byte `Copy` record with no owned allocations: a large
615/// protocol keeps one contiguous arena instead of one heap block per fragment.
616#[derive(Clone, Copy)]
617struct RenderFragmentMetadata {
618    /// Numeric slot of the fragment list this fragment renders into, avoiding a
619    /// string hash lookup per component, loop, condition, and template attribute.
620    /// [`NO_RENDER_SLOT`] when the fragment renders inline.
621    target: u32,
622    /// Offset into the index's shared attribute-name arena, or [`NO_ATTR_NAME`].
623    attr_start: u32,
624    attr_len: u32,
625}
626
627/// Build-time render plan for every fragment list in a protocol.
628///
629/// Built once when a [`Protocol`] is created and shared immutably by every
630/// render. Fragment IDs are the same `Arc<str>` values the protocol already
631/// interns, metadata lives in one flat arena, and the fragment graphs
632/// themselves are never duplicated.
633pub(crate) struct RenderFragmentIndex {
634    ids: Box<[Arc<str>]>,
635    metadata: Box<[RenderFragmentMetadata]>,
636    /// Prefix offsets into `metadata`; length is `ids.len() + 1`.
637    ranges: Box<[u32]>,
638    /// Every prepared component prop name concatenated into one allocation.
639    attr_names: Box<str>,
640    /// One bit per fragment list: does it contain a route fragment?
641    route_presence: Box<[u64]>,
642}
643
644/// A [`RenderFragmentIndex`] bound to the protocol document for one render.
645///
646/// Fragment lists are borrowed lazily and memoized in an inline slot cache, so
647/// a large protocol is never eagerly walked to serve a small render.
648pub(crate) struct ResolvedRenderFragmentIndex<'protocol> {
649    cache: [Cell<Option<&'protocol FragmentList>>; INLINE_RENDER_FRAGMENT_LISTS],
650    /// Memo slots for protocols with more fragment lists than the inline cache
651    /// holds, allocated on first use so a small render never pays for it and a
652    /// large one never falls back to hashing the fragment ID on every descent.
653    spilled: OnceCell<Box<[Cell<Option<&'protocol FragmentList>>]>>,
654    index: &'protocol RenderFragmentIndex,
655    protocol: &'protocol WebUIProtocol,
656}
657
658impl RenderFragmentIndex {
659    /// Prepare the render plan for `protocol`.
660    ///
661    /// `ids` must be the protocol's sorted fragment IDs and `slots` its ID→slot
662    /// map, so render slots and the runtime [`Protocol`]'s continuation slots are
663    /// the same numbering. Reusing the map keeps preparation to one hash lookup
664    /// per reference instead of a string binary search.
665    pub(crate) fn new(
666        protocol: &WebUIProtocol,
667        ids: &[Arc<str>],
668        slots: &HashMap<Arc<str>, u32>,
669    ) -> Self {
670        let total_fragments = protocol
671            .fragments
672            .values()
673            .map(|list| list.fragments.len())
674            .sum();
675        let mut metadata = Vec::with_capacity(total_fragments);
676        let mut ranges = Vec::with_capacity(ids.len() + 1);
677        // Most attribute names are short; reserving up front keeps the shared
678        // arena from repeatedly reallocating and copying as it is filled.
679        let mut attr_names = String::with_capacity(total_fragments * 8);
680        let mut route_presence = vec![0u64; ids.len().div_ceil(64)];
681
682        for (slot, id) in ids.iter().enumerate() {
683            // Record counts are bounded by the compiled graph, well inside u32.
684            #[allow(clippy::cast_possible_truncation)]
685            ranges.push(metadata.len() as u32);
686            let Some(list) = protocol.fragments.get(id.as_ref()) else {
687                continue;
688            };
689            let mut has_routes = false;
690            for fragment in &list.fragments {
691                let inner = fragment.fragment.as_ref();
692                if matches!(inner, Some(Fragment::Route(_))) {
693                    has_routes = true;
694                }
695                let target = fragment_target_id(fragment)
696                    .and_then(|target| slots.get(target).copied())
697                    .unwrap_or(NO_RENDER_SLOT);
698                let (attr_start, attr_len) = match inner {
699                    // `attr_skip` attributes are never collected into component
700                    // props, so preparing a name for them would be pure load-time
701                    // cost for something no render ever reads.
702                    Some(Fragment::Attribute(attribute)) if !attribute.attr_skip => {
703                        #[allow(clippy::cast_possible_truncation)]
704                        let start = attr_names.len() as u32;
705                        let before = attr_names.len();
706                        push_component_attr_name(&mut attr_names, component_attr_source(attribute));
707                        #[allow(clippy::cast_possible_truncation)]
708                        let len = (attr_names.len() - before) as u32;
709                        (start, len)
710                    }
711                    _ => (NO_ATTR_NAME, 0),
712                };
713                metadata.push(RenderFragmentMetadata {
714                    target,
715                    attr_start,
716                    attr_len,
717                });
718            }
719            if has_routes {
720                route_presence[slot / 64] |= 1u64 << (slot % 64);
721            }
722        }
723        #[allow(clippy::cast_possible_truncation)]
724        ranges.push(metadata.len() as u32);
725
726        Self {
727            ids: ids.to_vec().into_boxed_slice(),
728            metadata: metadata.into_boxed_slice(),
729            ranges: ranges.into_boxed_slice(),
730            attr_names: attr_names.into_boxed_str(),
731            route_presence: route_presence.into_boxed_slice(),
732        }
733    }
734
735    /// Resolve a fragment ID to its stable numeric render slot.
736    pub(crate) fn index(&self, id: &str) -> Option<usize> {
737        self.ids
738            .binary_search_by(|candidate| candidate.as_ref().cmp(id))
739            .ok()
740    }
741
742    pub(crate) fn resolve<'protocol>(
743        &'protocol self,
744        protocol: &'protocol WebUIProtocol,
745    ) -> ResolvedRenderFragmentIndex<'protocol> {
746        ResolvedRenderFragmentIndex {
747            cache: std::array::from_fn(|_| Cell::new(None)),
748            spilled: OnceCell::new(),
749            index: self,
750            protocol,
751        }
752    }
753}
754
755impl<'protocol> ResolvedRenderFragmentIndex<'protocol> {
756    pub(crate) fn index(&self, id: &str) -> Option<usize> {
757        self.index.index(id)
758    }
759
760    /// Memo slot for `index`, spilling past the inline cache on demand.
761    fn slot(&self, index: usize) -> Option<&Cell<Option<&'protocol FragmentList>>> {
762        if index < INLINE_RENDER_FRAGMENT_LISTS {
763            return self.cache.get(index);
764        }
765        let spilled = self.spilled.get_or_init(|| {
766            let overflow = self
767                .index
768                .ids
769                .len()
770                .saturating_sub(INLINE_RENDER_FRAGMENT_LISTS);
771            (0..overflow).map(|_| Cell::new(None)).collect()
772        });
773        spilled.get(index - INLINE_RENDER_FRAGMENT_LISTS)
774    }
775
776    pub(crate) fn list(&self, index: usize) -> Option<RenderFragmentList<'protocol>> {
777        let id = self.index.ids.get(index)?;
778        let fragment_list = match self.slot(index) {
779            Some(slot) => match slot.get() {
780                Some(cached) => cached,
781                None => {
782                    let resolved = self.protocol.fragments.get(id.as_ref())?;
783                    slot.set(Some(resolved));
784                    resolved
785                }
786            },
787            None => self.protocol.fragments.get(id.as_ref())?,
788        };
789        let start = *self.index.ranges.get(index)? as usize;
790        let end = *self.index.ranges.get(index + 1)? as usize;
791        let metadata = self.index.metadata.get(start..end)?;
792        let has_routes = self
793            .index
794            .route_presence
795            .get(index / 64)
796            .is_some_and(|word| word & (1u64 << (index % 64)) != 0);
797        Some(RenderFragmentList {
798            fragments: &fragment_list.fragments,
799            metadata,
800            attr_names: &self.index.attr_names,
801            contains_boundary: fragment_list.contains_boundary,
802            has_routes,
803        })
804    }
805
806    #[cfg(test)]
807    pub(crate) fn list_by_id(&self, id: &str) -> Option<RenderFragmentList<'protocol>> {
808        self.index(id).and_then(|index| self.list(index))
809    }
810}
811
812/// Name that a component prop is derived from for an attribute fragment.
813///
814/// The render path strips a leading `:` twice on the complex-value branch and
815/// once on every other branch. Every field that selects the branch is a
816/// build-time constant, so the choice is made here and the prepared name stays
817/// byte-identical to the per-render conversion it replaces.
818fn component_attr_source(attribute: &webui_protocol::WebUIFragmentAttribute) -> &str {
819    let takes_complex_branch = attribute.condition_tree.is_none()
820        && attribute.template.is_empty()
821        && !attribute.value.is_empty()
822        && !attribute.raw_value
823        && attribute.complex;
824    if takes_complex_branch {
825        attribute.name.strip_prefix(':').unwrap_or(&attribute.name)
826    } else {
827        &attribute.name
828    }
829}
830
831/// Route level one step below `children[index]`.
832///
833/// A borrowed level yields a borrowed sublevel, so descending through a route
834/// tree during a render never copies a `WebUiFragmentRoute`. A level a
835/// streaming continuation already materialized transfers its child level.
836fn descend_into<'protocol>(
837    children: &mut RouteChildren<'protocol>,
838    index: usize,
839) -> RouteChildren<'protocol> {
840    match children {
841        Cow::Borrowed(routes) => match routes.get(index) {
842            Some(route) => Cow::Borrowed(&route.children),
843            None => Cow::Borrowed(&[]),
844        },
845        Cow::Owned(routes) => match routes.get_mut(index) {
846            Some(route) => Cow::Owned(std::mem::take(&mut route.children)),
847            None => Cow::Borrowed(&[]),
848        },
849    }
850}
851
852/// Fragment ID a fragment descends into, or `None` when it renders inline.
853fn fragment_target_id(fragment: &WebUIFragment) -> Option<&str> {
854    match fragment.fragment.as_ref()? {
855        Fragment::Component(component) => Some(&component.fragment_id),
856        Fragment::ForLoop(for_loop) => Some(&for_loop.fragment_id),
857        Fragment::IfCond(if_cond) => Some(&if_cond.fragment_id),
858        Fragment::Attribute(attribute) if !attribute.template.is_empty() => {
859            Some(&attribute.template)
860        }
861        _ => None,
862    }
863}
864
865/// Context object for processing WebUI fragments
866pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> {
867    pub(crate) protocol: &'protocol WebUIProtocol,
868    /// Render plan prepared once when the runtime [`Protocol`] was loaded.
869    /// Fragment lists resolve through numeric slots instead of per-fragment
870    /// string hashing.
871    pub(crate) render_fragments: ResolvedRenderFragmentIndex<'protocol>,
872    /// Build-constant component asset style metadata serialized once when the
873    /// runtime [`Protocol`] is created.
874    pub(crate) component_asset_style_manifest: &'protocol str,
875    /// Deduplicated document-scoped component asset styles for Light DOM.
876    pub(crate) component_asset_style_links: &'protocol str,
877    pub(crate) state: &'state Value,
878    pub(crate) writer: &'output mut dyn ResponseWriter,
879    pub(crate) local_vars: HashMap<String, Value>,
880    /// Component-local values that still point into immutable request state.
881    local_borrowed_vars: BorrowedScope<'protocol, 'state>,
882    /// Borrowed loop bindings, in lexical order.
883    loop_vars: Vec<LoopBinding<'protocol, 'state>>,
884    /// Range of `loop_vars` visible to the current fragment scope. Component
885    /// bodies hide outer loop monikers while still allowing their own loops.
886    visible_loop_scope: VisibleLoopScope,
887    /// Accumulates component attribute values between attrStart and the component fragment.
888    pub(crate) component_attrs: HashMap<String, Value>,
889    /// State-backed component attributes accumulated without cloning.
890    component_borrowed_attrs: BorrowedScope<'protocol, 'state>,
891    /// True only while parser-produced component opening-tag attributes are
892    /// being accumulated. Native element attributes render directly and never
893    /// enter `component_attrs`.
894    collecting_component_attrs: bool,
895    /// URL path for server-side route matching. Borrowed from
896    /// `RenderOptions<'a>::request_path` — zero-copy.
897    pub(crate) request_path: &'protocol str,
898    /// Base path for resolving relative route paths (`./`).
899    /// Updated as the handler descends into nested matched routes.
900    /// `Cow` keeps the initial `"/"` literal zero-copy; nested-route
901    /// descent owns the recomputed path.
902    pub(crate) route_base: Cow<'protocol, str>,
903    /// Component names visited during rendering (for selective f-template emission
904    /// and CSS module dedup — only the first render of each component emits
905    /// its `<script type="importmap">` data-URI tag).
906    pub(crate) rendered_components: HashSet<String>,
907    /// Per-render plugin instance created from the handler's factory.
908    pub(crate) plugin: Option<Box<dyn HandlerPlugin>>,
909    /// Current position in the route tree for outlet-based rendering.
910    /// Contains the children of the currently matched route fragment.
911    ///
912    /// A matched route's `children` is a recursive prost subtree, so the
913    /// borrowed variant avoids deep-cloning the whole remaining route tree on
914    /// every matched route of every request. Only a suspended streaming
915    /// continuation, which outlives the borrow, materializes the owned variant.
916    pub(crate) route_children: RouteChildren<'protocol>,
917    /// Entry fragment ID — used to compute the initial inventory at head_end.
918    /// Borrowed from `RenderOptions<'a>::entry_id` — zero-copy.
919    pub(crate) entry_id: &'protocol str,
920    /// CSP nonce for inline `<script>` tags (None = no nonce attribute).
921    /// Borrowed from `RenderOptions<'a>::nonce` — zero-copy.
922    pub(crate) nonce: Option<&'protocol str>,
923    /// Component-name → bit-position map built once when the runtime
924    /// [`Protocol`] is created and shared by every render.
925    pub(crate) component_index: &'protocol HashMap<String, u32>,
926    /// Style-resource ID → request-local dedup bit built with [`Protocol`].
927    pub(crate) style_resource_index: &'protocol HashMap<String, u32>,
928    /// Inline style resources whose CSS contains a case-insensitive `</style`.
929    ///
930    /// Computed once when [`Protocol`] loads so repeated component instances can
931    /// write ordinary CSS without rescanning identical bytes on every request.
932    pub(crate) style_resources_requiring_escape: &'protocol HashSet<String>,
933    /// Component tag → covering bundle chunk, built once per render.
934    ///
935    /// Empty (and allocation-free) for unbundled builds. Every style delivery
936    /// path resolves closure members through this so they all agree on what a
937    /// chunk already ships.
938    pub(crate) style_chunk_index: HashMap<&'protocol str, u32>,
939    /// CSS strategy declared by the compiled protocol.
940    pub(crate) css_strategy: webui_protocol::CssStrategy,
941    /// HTML emitted at the structural `head_end` boundary (before
942    /// `</head>`), after the built-in nonce/CSS emissions.
943    /// Zero-copy borrow of the caller's `RenderOptions<'a>::head_inject`
944    /// (no per-render clone — saves an allocation when the host passes
945    /// a `&'static str` such as a dev livereload script).
946    pub(crate) head_inject: Option<&'protocol str>,
947    /// HTML emitted at the structural `body_end` boundary (before
948    /// `</body>`), after the built-in template metadata emissions.
949    /// Same zero-copy borrow as [`head_inject`](Self::head_inject).
950    pub(crate) body_inject: Option<&'protocol str>,
951    /// Host HTML resolved once per render from the reserved
952    /// [`STATE_INJECT_KEY`] state namespace. Emitted after the built-in
953    /// emissions at each structural boundary, and after the corresponding
954    /// `RenderOptions` inject at `head_end` and `body_end`.
955    pub(crate) state_inject: StateInject<'state>,
956    /// Tracks whether the `head_end` hook has already fired in this
957    /// render. Defends against malformed protocols that emit the
958    /// signal more than once (e.g., a template with multiple `<head>`
959    /// tags) — without this, host-supplied `head_inject` HTML, CSS
960    /// resources, and the CSP `<meta>` nonce would be
961    /// duplicated, which can be a CSP-bypass / cache-bloat vector.
962    pub(crate) head_end_emitted: bool,
963    /// Tracks whether the `body_start` hook has already fired in this
964    /// render. Defends against malformed protocols emitting the signal
965    /// twice — without this, state-supplied `bodyStart` HTML would be
966    /// duplicated.
967    pub(crate) body_start_emitted: bool,
968    /// Tracks whether compiler-owned component asset styles were emitted at
969    /// either the head or body fallback boundary.
970    pub(crate) component_asset_styles_emitted: bool,
971    /// Tracks whether the `body_end` hook has already fired in this
972    /// render. Defends against malformed protocols emitting the
973    /// signal twice — without this, hydration `<script>` blocks and
974    /// host-supplied `body_inject` would be duplicated.
975    pub(crate) body_end_emitted: bool,
976    /// Immutable authored route patterns compiled when [`Protocol`] is loaded.
977    pub(crate) route_index: &'protocol CompiledRouteIndex,
978    /// Counter for `data-ri` attributes on matched route elements.
979    /// Incremented each time a matched route is rendered, allowing O(1) element
980    /// binding on the client side instead of DOM-walking.
981    pub(crate) route_chain_index: usize,
982    /// Matched route metadata computed at most once when head styles or body
983    /// bootstrap first need it, then shared by streaming checkpoints.
984    pub(crate) route_chain: Option<Vec<crate::route_handler::RouteChainEntry>>,
985    /// Whether each matched route closure installs into the Document CSS tree.
986    ///
987    /// Kept parallel to `route_chain`; the route graph computes both in one walk.
988    pub(crate) route_document_style_targets: Vec<bool>,
989    /// Request-reachable components in deterministic first-discovery order.
990    ///
991    /// Resolved once at `head_end` for Shadow Link preloads, then consumed at
992    /// `body_end` for hydration metadata so the fragment graph is not walked
993    /// twice per request.
994    pub(crate) reachable_components: Option<Vec<String>>,
995    /// Present only for the opt-in progressive streaming render path.
996    pub(crate) streaming: Option<&'output mut StreamingRenderState<'protocol>>,
997    /// Reusable JSON serialization scratch buffer, owned by the render context.
998    /// The bootstrap/field/template helpers borrow it so each render reuses one
999    /// buffer across every serialized field and every streaming checkpoint
1000    /// instead of allocating per value. It grows lazily on first serialization
1001    /// and is dropped with the context at request end — no per-thread
1002    /// high-water buffer is retained between requests.
1003    pub(crate) json_scratch: Vec<u8>,
1004    /// Small request-local pool of cleared scope maps reused across sibling
1005    /// component roots. `process_component` recycles each finished component's
1006    /// local/attr map here instead of dropping it, so a sibling reuses the
1007    /// bucket capacity rather than reallocating a fresh `HashMap`. Bounded
1008    /// ([`SCOPE_POOL_CAP`]) and dropped with the context at request end.
1009    pub(crate) scope_pool: Vec<HashMap<String, Value>>,
1010    /// Resources delivered into the Document CSS tree. The empty set does not
1011    /// allocate, and streaming retains it across checkpoints.
1012    pub(crate) document_style_resources: HashSet<String>,
1013    /// Active Shadow roots and their route-activated resource indexes. The
1014    /// route vector allocates only when a matched Light route contributes CSS.
1015    pub(crate) shadow_style_roots: Vec<ShadowStyleRoot>,
1016    /// Capacity-preserving pool for borrowed component scope maps.
1017    borrowed_scope_pool: Vec<BorrowedScope<'protocol, 'state>>,
1018}
1019
1020/// Compiler-owned signal namespace. The leading `}}}` cannot be produced by
1021/// authored double- or triple-brace expressions because it closes the binding.
1022pub(crate) const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
1023
1024pub(crate) fn structural_signal_value(
1025    signal: &webui_protocol::WebUIFragmentSignal,
1026) -> Option<&str> {
1027    if !signal.raw {
1028        return None;
1029    }
1030    signal.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
1031}
1032
1033pub(crate) fn write_interaction_marker(
1034    tag: &str,
1035    context: &mut WebUIProcessContext<'_, '_, '_>,
1036) -> Result<()> {
1037    if matches!(
1038        context
1039            .protocol
1040            .components
1041            .get(tag)
1042            .and_then(|component| ComponentWorkPolicy::try_from(component.work_policy).ok()),
1043        Some(ComponentWorkPolicy::Interaction | ComponentWorkPolicy::LazyRenderInteraction)
1044    ) {
1045        context.writer.write(" data-webui-interaction")?;
1046    }
1047    Ok(())
1048}
1049
1050/// Find the end of a leading doctype without treating quoted `>` bytes in
1051/// legacy PUBLIC or SYSTEM identifiers as the declaration close.
1052fn doctype_prefix_end(raw: &str) -> Option<usize> {
1053    const PREFIX: &[u8] = b"<!doctype";
1054
1055    let bytes = raw.as_bytes();
1056    let mut cursor = usize::from(bytes.starts_with(b"\xEF\xBB\xBF")) * 3;
1057    while bytes
1058        .get(cursor)
1059        .is_some_and(|byte| byte.is_ascii_whitespace())
1060    {
1061        cursor += 1;
1062    }
1063
1064    let prefix_end = cursor.checked_add(PREFIX.len())?;
1065    if !bytes.get(cursor..prefix_end)?.eq_ignore_ascii_case(PREFIX) {
1066        return None;
1067    }
1068    let separator = *bytes.get(prefix_end)?;
1069    if separator != b'>' && !separator.is_ascii_whitespace() {
1070        return None;
1071    }
1072
1073    let mut quote = None;
1074    for (offset, byte) in bytes[prefix_end..].iter().copied().enumerate() {
1075        match quote {
1076            Some(active) if byte == active => quote = None,
1077            Some(_) => {}
1078            None if matches!(byte, b'\'' | b'"') => quote = Some(byte),
1079            None if byte == b'>' => return Some(prefix_end + offset + 1),
1080            None => {}
1081        }
1082    }
1083    None
1084}
1085
1086/// Maximum scope maps retained in the request-local pool. Small: sibling
1087/// component roots rarely nest deeply, so the cap keeps retained capacity bounded.
1088const SCOPE_POOL_CAP: usize = 8;
1089/// Borrowed component scopes typically contain only a handful of attributes.
1090const INLINE_SCOPE_SLOTS: usize = 4;
1091/// Fragment lists memoized inline per render. Sized to cover the distinct lists
1092/// a typical app touches in one render while keeping the per-render zeroing cost
1093/// negligible; larger protocols fall back to the protocol map for the overflow
1094/// slots instead of eagerly materializing every list.
1095/// Inline memo slots for fragment lists.
1096///
1097/// Deliberately small: the array is zeroed on every render, so a shallow render
1098/// touching two fragments should not pay to clear slots it will never read.
1099/// Deeper renders spill into a lazily allocated slice, which keeps them fully
1100/// memoized without charging that cost to the common shallow case.
1101const INLINE_RENDER_FRAGMENT_LISTS: usize = 8;
1102const COMPONENT_ASSET_MANIFEST_ID: &str = "webui-component-assets";
1103
1104struct ComponentAssetStyleManifest<'a>(&'a [ComponentAssetStylePreload]);
1105
1106impl Serialize for ComponentAssetStyleManifest<'_> {
1107    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1108    where
1109        S: serde::Serializer,
1110    {
1111        let mut map = serializer.serialize_map(Some(self.0.len()))?;
1112        for entry in self.0 {
1113            map.serialize_entry(&entry.root, &entry.style_hrefs)?;
1114        }
1115        map.end()
1116    }
1117}
1118
1119fn serialize_component_asset_style_manifest(
1120    preloads: &[ComponentAssetStylePreload],
1121) -> std::result::Result<String, serde_json::Error> {
1122    if preloads.is_empty() {
1123        return Ok(String::new());
1124    }
1125    let json = serde_json::to_string(&ComponentAssetStyleManifest(preloads))?;
1126    if json.contains("</") {
1127        Ok(json.replace("</", "<\\/"))
1128    } else {
1129        Ok(json)
1130    }
1131}
1132
1133fn serialize_component_asset_style_links(preloads: &[ComponentAssetStylePreload]) -> String {
1134    let href_count = preloads.iter().map(|entry| entry.style_hrefs.len()).sum();
1135    let mut seen = HashSet::with_capacity(href_count);
1136    let mut links = String::with_capacity(href_count.saturating_mul(48));
1137    for entry in preloads {
1138        for href in &entry.style_hrefs {
1139            if !seen.insert(href.as_str()) {
1140                continue;
1141            }
1142            links.push_str("<link rel=\"stylesheet\" href=\"");
1143            push_escaped_html_attribute(&mut links, href);
1144            links.push_str("\">");
1145        }
1146    }
1147    links
1148}
1149
1150fn push_escaped_html_attribute(output: &mut String, value: &str) {
1151    let mut start = 0;
1152    for (index, ch) in value.char_indices() {
1153        let escaped = match ch {
1154            '&' => Some("&amp;"),
1155            '"' => Some("&quot;"),
1156            '<' => Some("&lt;"),
1157            '>' => Some("&gt;"),
1158            _ => None,
1159        };
1160        let Some(entity) = escaped else {
1161            continue;
1162        };
1163        if start < index {
1164            output.push_str(&value[start..index]);
1165        }
1166        output.push_str(entity);
1167        start = index + ch.len_utf8();
1168    }
1169    if start < value.len() {
1170        output.push_str(&value[start..]);
1171    }
1172}
1173
1174/// Take a cleared scope map from the pool, or a fresh empty one when the pool is
1175/// empty. A fresh `HashMap` does not allocate until its first insert.
1176fn take_scope_map(pool: &mut Vec<HashMap<String, Value>>) -> HashMap<String, Value> {
1177    pool.pop().unwrap_or_default()
1178}
1179
1180/// Return a spent scope map to the pool, clearing it but retaining its bucket
1181/// capacity for a sibling root to reuse. Drops the map once the pool is full so
1182/// retained memory stays bounded.
1183fn recycle_scope_map(pool: &mut Vec<HashMap<String, Value>>, mut map: HashMap<String, Value>) {
1184    if pool.len() < SCOPE_POOL_CAP {
1185        map.clear();
1186        pool.push(map);
1187    }
1188}
1189
1190fn take_borrowed_scope<'protocol, 'state>(
1191    pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1192) -> BorrowedScope<'protocol, 'state> {
1193    pool.pop().unwrap_or_default()
1194}
1195
1196fn recycle_borrowed_scope<'protocol, 'state>(
1197    pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1198    mut scope: BorrowedScope<'protocol, 'state>,
1199) {
1200    if pool.len() < SCOPE_POOL_CAP {
1201        scope.clear();
1202        pool.push(scope);
1203    }
1204}
1205
1206pub(crate) enum WebUiBootstrapState<'a> {
1207    Complete {
1208        value: &'a Value,
1209        selection: StateSelection<'a>,
1210    },
1211    Reference {
1212        record_sequence: usize,
1213        value: &'a Value,
1214        delta: Option<StateSelection<'a>>,
1215    },
1216}
1217
1218pub(crate) struct WebUiBootstrap<'a, ComponentStyles: Serialize + ?Sized = Value> {
1219    pub(crate) declaration_id: Option<u32>,
1220    pub(crate) enclosing_span_instance_id: Option<u32>,
1221    pub(crate) state: WebUiBootstrapState<'a>,
1222    pub(crate) chain: &'a [Value],
1223    pub(crate) inventory: &'a str,
1224    pub(crate) nonce: Option<&'a str>,
1225    pub(crate) css_hrefs: &'a [&'a str],
1226    pub(crate) style_specs: &'a [&'a str],
1227    pub(crate) component_styles: &'a ComponentStyles,
1228    pub(crate) templates: &'a [WebUiTemplatePayload<'a>],
1229}
1230
1231/// Get the component attribute name, stripping `:` prefix and converting to camelCase.
1232///
1233/// Uses `webui_protocol::attrs::attribute_to_camel` which handles irregular
1234/// attributes (multi-word ARIA and global HTML attributes like `readonly`,
1235/// `tabindex`) via the shared lookup table.
1236///
1237/// Renders read prepared names out of [`RenderFragmentIndex`] instead of calling
1238/// this, so it remains only as the single-name form of
1239/// [`push_component_attr_name`] for tests.
1240#[cfg(test)]
1241fn component_attr_name(name: &str) -> String {
1242    let mut buffer = String::new();
1243    push_component_attr_name(&mut buffer, name);
1244    buffer
1245}
1246
1247/// Append the canonical camelCase component prop name for `name` to `buffer`.
1248///
1249/// Strips a leading `:` and converts via `webui_protocol::attrs::attribute_to_camel`,
1250/// which handles irregular attributes (multi-word ARIA and global HTML attributes
1251/// like `readonly`, `tabindex`) through the shared lookup table.
1252///
1253/// Used while preparing the render fragment index so a protocol with thousands
1254/// of attribute fragments grows one arena instead of allocating per name.
1255fn push_component_attr_name(buffer: &mut String, name: &str) {
1256    let stripped = name.strip_prefix(':').unwrap_or(name);
1257    // Same conversion as `webui_protocol::attrs::attribute_to_camel`, written
1258    // straight into the shared arena. That function returns an owned `String`,
1259    // so calling it here would allocate and drop one buffer per attribute while
1260    // preparing a protocol.
1261    if let Some(property) = webui_protocol::attrs::attribute_to_property(stripped) {
1262        buffer.push_str(property);
1263        return;
1264    }
1265    buffer.reserve(stripped.len());
1266    let mut capitalize_next = false;
1267    for ch in stripped.chars() {
1268        if ch == '-' {
1269            capitalize_next = true;
1270        } else if capitalize_next {
1271            buffer.extend(ch.to_uppercase());
1272            capitalize_next = false;
1273        } else {
1274            buffer.push(ch);
1275        }
1276    }
1277}
1278
1279#[cold]
1280#[inline(never)]
1281fn missing_component_attr_name_error() -> HandlerError {
1282    HandlerError::Invariant("prepared component attribute name is missing".to_string())
1283}
1284
1285/// Fixed-capacity [`fmt::Write`] sink used to render JSON scalars on the stack.
1286///
1287/// `serde_json::Number` is backed by `i64`, `u64`, or `f64` (the crate is built
1288/// without `arbitrary_precision`), so its longest rendering is the 24-byte
1289/// `ryu` form of an extreme `f64`. The buffer is oversized well past that, and
1290/// an overflow is reported rather than truncated so callers fall back to the
1291/// allocating path instead of emitting a malformed value.
1292struct ScalarBuffer {
1293    buf: [u8; 48],
1294    len: usize,
1295}
1296
1297impl ScalarBuffer {
1298    #[inline]
1299    const fn new() -> Self {
1300        Self {
1301            buf: [0; 48],
1302            len: 0,
1303        }
1304    }
1305
1306    #[inline]
1307    fn as_str(&self) -> Option<&str> {
1308        std::str::from_utf8(&self.buf[..self.len]).ok()
1309    }
1310}
1311
1312impl fmt::Write for ScalarBuffer {
1313    #[inline]
1314    fn write_str(&mut self, s: &str) -> fmt::Result {
1315        let bytes = s.as_bytes();
1316        let end = self.len.checked_add(bytes.len()).ok_or(fmt::Error)?;
1317        let slot = self.buf.get_mut(self.len..end).ok_or(fmt::Error)?;
1318        slot.copy_from_slice(bytes);
1319        self.len = end;
1320        Ok(())
1321    }
1322}
1323
1324/// Render a JSON scalar that can never contain HTML-significant bytes.
1325///
1326/// Numbers, booleans, and `null` render identically escaped and unescaped, so
1327/// this bypasses both the `Value::to_string` allocation and the `encode_safe`
1328/// scan that the generic display path performs. Returns `None` when `value` is
1329/// a string, array, or object, leaving those to the caller's fallback.
1330fn format_plain_json_scalar<'buf>(
1331    buffer: &'buf mut ScalarBuffer,
1332    value: &Value,
1333) -> Option<&'buf str> {
1334    match value {
1335        Value::Bool(true) => Some("true"),
1336        Value::Bool(false) => Some("false"),
1337        Value::Null => Some("null"),
1338        Value::Number(number) => {
1339            // `Display for Number` is the same serializer `Value::to_string`
1340            // drives, so the bytes are identical to the allocating path.
1341            write!(buffer, "{number}").ok()?;
1342            buffer.as_str()
1343        }
1344        _ => None,
1345    }
1346}
1347
1348/// Write a JSON scalar straight to the sink, skipping escaping and allocation.
1349///
1350/// Returns `false` when `value` needs the caller's allocating fallback.
1351fn write_plain_json_scalar(writer: &mut dyn ResponseWriter, value: &Value) -> Result<bool> {
1352    let mut buffer = ScalarBuffer::new();
1353    match format_plain_json_scalar(&mut buffer, value) {
1354        Some(rendered) => {
1355            writer.write(rendered)?;
1356            Ok(true)
1357        }
1358        None => Ok(false),
1359    }
1360}
1361
1362/// Write a usize as decimal digits directly to the writer, avoiding `format!` allocation.
1363pub(crate) fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
1364    if n == 0 {
1365        return writer.write("0");
1366    }
1367    // Max digits for a 64-bit usize is 20.
1368    let mut buf = [0u8; 20];
1369    let mut pos = buf.len();
1370    while n > 0 {
1371        pos -= 1;
1372        // n % 10 is always in 0..=9, fits in u8 without truncation.
1373        #[allow(clippy::cast_possible_truncation)]
1374        let digit = (n % 10) as u8;
1375        buf[pos] = b'0' + digit;
1376        n /= 10;
1377    }
1378    // Digits are always valid ASCII/UTF-8.
1379    match std::str::from_utf8(&buf[pos..]) {
1380        Ok(s) => writer.write(s),
1381        Err(_) => writer.write("0"),
1382    }
1383}
1384
1385pub(crate) fn write_script_safe_json<T>(
1386    writer: &mut dyn ResponseWriter,
1387    scratch: &mut Vec<u8>,
1388    value: &T,
1389) -> Result<()>
1390where
1391    T: Serialize + ?Sized,
1392{
1393    // Serialize into the caller's request-local `scratch`. The streaming path
1394    // emits one bootstrap envelope per committed boundary and the ordinary
1395    // body_end bootstrap serializes several fields, so reusing one buffer across
1396    // the render avoids a fresh allocation per value. The buffer grows lazily on
1397    // first use (no allocation until serialization needs it) and is dropped with
1398    // the render context — capacity is reused within a request but never
1399    // retained across requests.
1400    scratch.clear();
1401    serde_json::to_writer(&mut *scratch, value)
1402        .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
1403    let json = std::str::from_utf8(scratch)
1404        .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
1405    write_script_safe_json_str(writer, json)
1406}
1407
1408fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
1409    let mut start = 0;
1410    while start < json.len() {
1411        let rest = &json[start..];
1412        let Some(offset) = rest.find("</") else {
1413            writer.write(rest)?;
1414            return Ok(());
1415        };
1416
1417        if offset > 0 {
1418            writer.write(&rest[..offset])?;
1419        }
1420        writer.write("<\\/")?;
1421        start += offset + 2;
1422    }
1423    Ok(())
1424}
1425
1426fn write_json_field_name(
1427    writer: &mut dyn ResponseWriter,
1428    wrote_field: &mut bool,
1429    name: &str,
1430) -> Result<()> {
1431    if *wrote_field {
1432        writer.write(",")?;
1433    }
1434    *wrote_field = true;
1435    writer.write("\"")?;
1436    writer.write(name)?;
1437    writer.write("\":")
1438}
1439
1440fn write_json_field<T>(
1441    writer: &mut dyn ResponseWriter,
1442    scratch: &mut Vec<u8>,
1443    wrote_field: &mut bool,
1444    name: &str,
1445    value: &T,
1446) -> Result<()>
1447where
1448    T: Serialize + ?Sized,
1449{
1450    write_json_field_name(writer, wrote_field, name)?;
1451    write_script_safe_json(writer, scratch, value)
1452}
1453
1454/// Serialize wrapper that projects an SSR state object down to only the
1455/// keys present in the build-time hydration allowlist.
1456///
1457/// This is the runtime half of the projected-hydration design: instead of
1458/// serializing the entire application state (potentially megabytes) on every
1459/// full-HTML render, only the fields a component actually hydrates are
1460/// emitted. The request allowlist conservatively includes every reachable
1461/// component's hydration keys so no field a component needs is dropped.
1462///
1463/// Projection is a payload boundary, not a secrecy boundary. Any key selected
1464/// by compiled client metadata is browser-facing, so hosts must never place
1465/// secrets in browser render state.
1466///
1467/// `keys` MUST be sorted and deduplicated. Projection iterates whichever side
1468/// is smaller: hydration keys with direct map lookup for wide states, or state
1469/// entries with binary-search membership for compact states. Non-object states
1470/// carry nothing hydratable and serialize as an empty object.
1471struct ProjectedState<'a> {
1472    value: &'a Value,
1473    keys: KeyView<'a>,
1474}
1475
1476impl Serialize for ProjectedState<'_> {
1477    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1478    where
1479        S: serde::Serializer,
1480    {
1481        let Value::Object(map) = self.value else {
1482            return serializer.serialize_map(Some(0))?.end();
1483        };
1484
1485        let mut out = serializer.serialize_map(None)?;
1486        if self.keys.len() < map.len() {
1487            let mut previous = None;
1488            for key in self.keys.iter() {
1489                if key == STATE_INJECT_KEY {
1490                    continue;
1491                }
1492                if previous == Some(key) {
1493                    continue;
1494                }
1495                previous = Some(key);
1496                if let Some(value) = map.get(key) {
1497                    out.serialize_entry(key, value)?;
1498                }
1499            }
1500        } else {
1501            for (key, value) in map {
1502                if key == STATE_INJECT_KEY {
1503                    continue;
1504                }
1505                if self.keys.contains(key.as_str()) {
1506                    out.serialize_entry(key, value)?;
1507                }
1508            }
1509        }
1510        out.end()
1511    }
1512}
1513
1514/// Write the SSR `state` into the bootstrap block according to the protocol's
1515/// build-time selection and escape it for safe embedding in a `<script>`.
1516///
1517/// [`ProjectedState`] serializes only the allowlisted keys, so for the typical
1518/// payload — a large state with a small hydratable surface — serde ever only
1519/// touches the projected subset. Serialization reuses the proven
1520/// [`write_script_safe_json`] path (serde's fast `Vec<u8>` target plus a single
1521/// SIMD-accelerated `</` escape pass), which matches the pre-projection cost
1522/// when every key is hydratable and collapses to a few bytes when it is not.
1523/// Buffering the projected bytes and escaping once is measurably faster than
1524/// streaming through a per-token `io::Write` adapter, and the projected buffer
1525/// is tiny in the common case.
1526/// Serialize a state object with the reserved [`STATE_INJECT_KEY`] entry
1527/// omitted.
1528///
1529/// The reserved key carries host-supplied boundary HTML, not application
1530/// state, so it must never reach the client hydration payload — shipping it
1531/// would both duplicate the markup as a JSON string and expose the host's
1532/// inject channel to client code. Filtering happens during serialization so
1533/// the state tree is never cloned.
1534struct StateWithoutReservedKey<'a> {
1535    value: &'a Value,
1536}
1537
1538impl Serialize for StateWithoutReservedKey<'_> {
1539    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1540    where
1541        S: serde::Serializer,
1542    {
1543        let Value::Object(map) = self.value else {
1544            return self.value.serialize(serializer);
1545        };
1546        let mut out = serializer.serialize_map(Some(map.len().saturating_sub(1)))?;
1547        for (key, value) in map {
1548            if key == STATE_INJECT_KEY {
1549                continue;
1550            }
1551            out.serialize_entry(key, value)?;
1552        }
1553        out.end()
1554    }
1555}
1556
1557/// Write the complete state, stripping the reserved inject key when present.
1558///
1559/// The membership test is a single map lookup and the common case — no
1560/// reserved key — takes the original zero-overhead path unchanged.
1561fn write_full_state(
1562    writer: &mut dyn ResponseWriter,
1563    scratch: &mut Vec<u8>,
1564    state: &Value,
1565) -> Result<()> {
1566    if matches!(state, Value::Object(map) if map.contains_key(STATE_INJECT_KEY)) {
1567        return write_script_safe_json(writer, scratch, &StateWithoutReservedKey { value: state });
1568    }
1569    write_script_safe_json(writer, scratch, state)
1570}
1571
1572pub(crate) fn write_selected_state(
1573    writer: &mut dyn ResponseWriter,
1574    scratch: &mut Vec<u8>,
1575    state: &Value,
1576    selection: &StateSelection<'_>,
1577) -> Result<()> {
1578    let keys = match selection {
1579        StateSelection::Full => return write_full_state(writer, scratch, state),
1580        StateSelection::Keys(keys) => KeyView::Borrowed(keys.as_slice()),
1581        StateSelection::KeyIds(selection) => KeyView::Ids(*selection),
1582        StateSelection::FullExceptKeyIds(selection) => {
1583            return write_script_safe_json(
1584                writer,
1585                scratch,
1586                &StateWithoutSelectedKeys {
1587                    value: state,
1588                    keys: KeyView::Ids(*selection),
1589                },
1590            );
1591        }
1592    };
1593    if keys.is_empty() {
1594        return writer.write("{}");
1595    }
1596
1597    // Projection membership may use binary search, so a mis-sorted key set
1598    // would silently drop hydration keys. The key allowlist is produced sorted +
1599    // deduped at build time; this guard makes hand-built protocols that violate
1600    // the invariant fail loudly in tests at zero release cost.
1601    debug_assert!(
1602        keys.iter()
1603            .zip(keys.iter().skip(1))
1604            .all(|(left, right)| left <= right),
1605        "hydration keys must be sorted for binary-search projection"
1606    );
1607    if let Value::Object(map) = state {
1608        let selects_entire_map =
1609            keys.len() == map.len() && keys.iter().eq(map.keys().map(String::as_str));
1610        if selects_entire_map {
1611            return write_full_state(writer, scratch, state);
1612        }
1613    }
1614    write_script_safe_json(writer, scratch, &ProjectedState { value: state, keys })
1615}
1616
1617// Covers the common route surface without trusting protocol-derived counts for
1618// an eager allocation; larger key sets grow only as actual keys are visited.
1619pub(crate) const INITIAL_KEY_CAPACITY: usize = 16;
1620
1621/// Request-scoped state selection derived from reachable component metadata.
1622pub(crate) enum StateSelection<'a> {
1623    /// Preserve the complete state value.
1624    Full,
1625    /// Project an object to a sorted, deduplicated key allowlist.
1626    Keys(Vec<&'a str>),
1627    /// Project using the streaming continuation's interned hydration key IDs.
1628    ///
1629    /// IDs are assigned in lexicographic order, so a sorted ID slice is a
1630    /// sorted key slice and the projection needs no borrowed-string buffer at
1631    /// all — the streaming record keeps its scratch as plain integers that
1632    /// survive every semantic step.
1633    KeyIds(HydrationKeySelection<'a>),
1634    /// Preserve full state except keys inherited from a referenced range state.
1635    FullExceptKeyIds(HydrationKeySelection<'a>),
1636}
1637
1638/// A projection expressed as interned hydration key IDs.
1639#[derive(Clone, Copy)]
1640pub(crate) struct HydrationKeySelection<'a> {
1641    pub(crate) ids: &'a [u32],
1642    pub(crate) index: &'a ComponentReachabilityIndex,
1643}
1644
1645impl<'a> HydrationKeySelection<'a> {
1646    fn key(self, position: usize) -> Option<&'a str> {
1647        let id = self.ids.get(position).copied()?;
1648        self.index.hydration_key(id)
1649    }
1650}
1651
1652/// A sorted, deduplicated key allowlist in whichever form its producer holds.
1653#[derive(Clone, Copy)]
1654enum KeyView<'a> {
1655    Borrowed(&'a [&'a str]),
1656    Ids(HydrationKeySelection<'a>),
1657}
1658
1659impl<'a> KeyView<'a> {
1660    fn len(self) -> usize {
1661        match self {
1662            Self::Borrowed(keys) => keys.len(),
1663            Self::Ids(selection) => selection.ids.len(),
1664        }
1665    }
1666
1667    fn is_empty(self) -> bool {
1668        self.len() == 0
1669    }
1670
1671    fn get(self, position: usize) -> Option<&'a str> {
1672        match self {
1673            Self::Borrowed(keys) => keys.get(position).copied(),
1674            Self::Ids(selection) => selection.key(position),
1675        }
1676    }
1677
1678    fn iter(self) -> impl Iterator<Item = &'a str> {
1679        (0..self.len()).map_while(move |position| self.get(position))
1680    }
1681
1682    /// Sorted-membership probe used when the state object is smaller than the
1683    /// allowlist.
1684    fn contains(self, key: &str) -> bool {
1685        match self {
1686            Self::Borrowed(keys) => keys
1687                .binary_search_by(|candidate| str::cmp(candidate, key))
1688                .is_ok(),
1689            Self::Ids(selection) => selection
1690                .ids
1691                .binary_search_by(|id| {
1692                    selection
1693                        .index
1694                        .hydration_key(*id)
1695                        .map_or(std::cmp::Ordering::Less, |candidate| {
1696                            str::cmp(candidate, key)
1697                        })
1698                })
1699                .is_ok(),
1700        }
1701    }
1702}
1703
1704#[derive(Clone, Copy)]
1705enum ComponentStateSurface {
1706    Hydration,
1707    Navigation,
1708}
1709
1710/// Select initial state for the components reachable on this request path.
1711///
1712/// Non-WebUI protocols preserve full state without walking component surfaces.
1713/// WebUI protocols project exact surfaces, while any unknown surface restores
1714/// the full state for correctness.
1715pub(crate) fn collect_hydration_state<'a, 'b>(
1716    protocol: &'a WebUIProtocol,
1717    components: impl IntoIterator<Item = &'b str>,
1718) -> StateSelection<'a> {
1719    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1720        return StateSelection::Full;
1721    }
1722    collect_component_state(protocol, components, ComponentStateSurface::Hydration)
1723}
1724
1725/// Fill a reusable hydration key-ID allowlist for a streaming record.
1726///
1727/// Components arrive as inventory indexes, so the projection never resolves a
1728/// component name, never hashes it against the compiled component map, and
1729/// never sorts strings: the interned runs concatenate and the integer sort
1730/// leaves the IDs in lexicographic key order. Returns `true` when correctness
1731/// requires sending full state instead.
1732pub(crate) fn collect_hydration_key_ids_into(
1733    protocol: &WebUIProtocol,
1734    index: &ComponentReachabilityIndex,
1735    components: impl IntoIterator<Item = u32>,
1736    ids: &mut Vec<u32>,
1737) -> bool {
1738    ids.clear();
1739    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1740        return true;
1741    }
1742    for component in components {
1743        if index.extend_hydration_keys(component, ids) {
1744            ids.clear();
1745            return true;
1746        }
1747    }
1748    ids.sort_unstable();
1749    ids.dedup();
1750    false
1751}
1752
1753/// Select state for client-created components reachable during navigation.
1754pub(crate) fn collect_navigation_state<'a, 'b>(
1755    protocol: &'a WebUIProtocol,
1756    components: impl IntoIterator<Item = &'b str>,
1757) -> StateSelection<'a> {
1758    collect_component_state(protocol, components, ComponentStateSurface::Navigation)
1759}
1760
1761fn collect_component_state<'a, 'b>(
1762    protocol: &'a WebUIProtocol,
1763    components: impl IntoIterator<Item = &'b str>,
1764    surface: ComponentStateSurface,
1765) -> StateSelection<'a> {
1766    let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
1767    if collect_component_state_into(protocol, components, surface, &mut keys) {
1768        StateSelection::Full
1769    } else {
1770        StateSelection::Keys(keys)
1771    }
1772}
1773
1774/// Fill a reusable state-key allowlist. Returns `true` when correctness
1775/// requires sending full state instead of the collected keys.
1776fn collect_component_state_into<'a, 'b>(
1777    protocol: &'a WebUIProtocol,
1778    components: impl IntoIterator<Item = &'b str>,
1779    surface: ComponentStateSurface,
1780    keys: &mut Vec<&'a str>,
1781) -> bool {
1782    keys.clear();
1783    for name in components {
1784        let Some(component) = protocol.components.get(name) else {
1785            return true;
1786        };
1787        let (mode, component_keys) = match surface {
1788            ComponentStateSurface::Hydration => {
1789                (component.hydration_mode, &component.hydration_keys)
1790            }
1791            ComponentStateSurface::Navigation => (
1792                match component.navigation_mode {
1793                    Some(mode) => mode,
1794                    None if !component.navigation_keys.is_empty() => {
1795                        StateProjectionMode::Keys as i32
1796                    }
1797                    None => return true,
1798                },
1799                &component.navigation_keys,
1800            ),
1801        };
1802        if mode == StateProjectionMode::All as i32 {
1803            return true;
1804        }
1805        if mode == StateProjectionMode::Keys as i32
1806            || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
1807        {
1808            keys.extend(component_keys.iter().map(String::as_str));
1809        } else if mode != StateProjectionMode::None as i32 {
1810            return true;
1811        }
1812    }
1813    keys.sort_unstable();
1814    keys.dedup();
1815    false
1816}
1817
1818pub(crate) fn write_webui_bootstrap<ComponentStyles: Serialize + ?Sized>(
1819    writer: &mut dyn ResponseWriter,
1820    scratch: &mut Vec<u8>,
1821    bootstrap: WebUiBootstrap<'_, ComponentStyles>,
1822) -> Result<()> {
1823    let mut wrote_field = false;
1824
1825    writer.write("{")?;
1826    if let Some(declaration_id) = bootstrap.declaration_id {
1827        write_json_field(
1828            writer,
1829            scratch,
1830            &mut wrote_field,
1831            "declarationId",
1832            &declaration_id,
1833        )?;
1834    }
1835    if let Some(span_id) = bootstrap.enclosing_span_instance_id {
1836        write_json_field(
1837            writer,
1838            scratch,
1839            &mut wrote_field,
1840            "enclosingSpanInstanceId",
1841            &span_id,
1842        )?;
1843    }
1844    if !bootstrap.chain.is_empty() {
1845        write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?;
1846    }
1847    // Definitions must be visible before template metadata can cause the
1848    // runtime to create a component root.
1849    write_json_field(
1850        writer,
1851        scratch,
1852        &mut wrote_field,
1853        "componentStyles",
1854        bootstrap.component_styles,
1855    )?;
1856    if !bootstrap.css_hrefs.is_empty() {
1857        write_json_field(
1858            writer,
1859            scratch,
1860            &mut wrote_field,
1861            "css",
1862            bootstrap.css_hrefs,
1863        )?;
1864    }
1865    write_json_field(
1866        writer,
1867        scratch,
1868        &mut wrote_field,
1869        "inventory",
1870        bootstrap.inventory,
1871    )?;
1872    if let Some(nonce) = bootstrap.nonce {
1873        write_json_field(writer, scratch, &mut wrote_field, "nonce", nonce)?;
1874    }
1875    match bootstrap.state {
1876        WebUiBootstrapState::Complete { value, selection } => {
1877            write_json_field_name(writer, &mut wrote_field, "state")?;
1878            write_selected_state(writer, scratch, value, &selection)?;
1879        }
1880        WebUiBootstrapState::Reference {
1881            record_sequence,
1882            value,
1883            delta,
1884        } => {
1885            write_json_field(
1886                writer,
1887                scratch,
1888                &mut wrote_field,
1889                "stateRef",
1890                &record_sequence,
1891            )?;
1892            if let Some(delta) = delta {
1893                write_json_field_name(writer, &mut wrote_field, "stateDelta")?;
1894                write_selected_state(writer, scratch, value, &delta)?;
1895            }
1896        }
1897    }
1898    if !bootstrap.style_specs.is_empty() {
1899        write_json_field(
1900            writer,
1901            scratch,
1902            &mut wrote_field,
1903            "styles",
1904            bootstrap.style_specs,
1905        )?;
1906    }
1907    if bootstrap
1908        .templates
1909        .iter()
1910        .any(|template| !template.template_json.is_empty())
1911    {
1912        write_json_field_name(writer, &mut wrote_field, "templates")?;
1913        write_webui_template_json_map(writer, scratch, bootstrap.templates)?;
1914    }
1915    writer.write("}")
1916}
1917
1918fn write_webui_data_block(
1919    writer: &mut dyn ResponseWriter,
1920    scratch: &mut Vec<u8>,
1921    bootstrap: WebUiBootstrap<'_>,
1922) -> Result<()> {
1923    writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
1924    if let Some(nonce) = bootstrap.nonce {
1925        writer.write(" nonce=\"")?;
1926        writer.write(nonce)?;
1927        writer.write("\"")?;
1928    }
1929
1930    writer.write(">")?;
1931    write_webui_bootstrap(writer, scratch, bootstrap)?;
1932    writer.write("</script>\n")
1933}
1934
1935fn write_component_asset_style_manifest(
1936    writer: &mut dyn ResponseWriter,
1937    manifest: &str,
1938    nonce: Option<&str>,
1939) -> Result<()> {
1940    if manifest.is_empty() {
1941        return Ok(());
1942    }
1943    writer.write("<script type=\"application/json\" id=\"")?;
1944    writer.write(COMPONENT_ASSET_MANIFEST_ID)?;
1945    writer.write("\"")?;
1946    if let Some(nonce) = nonce {
1947        writer.write(" nonce=\"")?;
1948        writer.write(&crate::html_encode::encode_safe(nonce))?;
1949        writer.write("\"")?;
1950    }
1951    writer.write(">")?;
1952    writer.write(manifest)?;
1953    writer.write("</script>")
1954}
1955
1956fn write_component_asset_styles(context: &mut WebUIProcessContext<'_, '_, '_>) -> Result<()> {
1957    if context.component_asset_styles_emitted {
1958        return Ok(());
1959    }
1960    context.component_asset_styles_emitted = true;
1961    context.writer.write(context.component_asset_style_links)?;
1962    write_component_asset_style_manifest(
1963        context.writer,
1964        context.component_asset_style_manifest,
1965        context.nonce,
1966    )
1967}
1968
1969fn write_webui_template_json_map(
1970    writer: &mut dyn ResponseWriter,
1971    scratch: &mut Vec<u8>,
1972    templates: &[WebUiTemplatePayload<'_>],
1973) -> Result<()> {
1974    writer.write("{")?;
1975    let mut wrote = false;
1976    for template in templates {
1977        if template.template_json.is_empty() {
1978            continue;
1979        }
1980        if wrote {
1981            writer.write(",")?;
1982        }
1983        wrote = true;
1984        write_script_safe_json(writer, scratch, template.tag_name)?;
1985        writer.write(":")?;
1986        write_script_safe_json_str(writer, template.template_json)?;
1987    }
1988    writer.write("}")
1989}
1990
1991fn resolve_value_from_sources<'ctx, 'state>(
1992    path: &str,
1993    loop_vars: &'ctx [LoopBinding<'_, 'state>],
1994    visible_loop_scope: VisibleLoopScope,
1995    local_values: LocalValueSources<'ctx, '_, 'state>,
1996    state: &'state Value,
1997) -> Option<Cow<'ctx, Value>>
1998where
1999    'state: 'ctx,
2000{
2001    if let Some(binding) = loop_vars
2002        .get(visible_loop_scope.start..visible_loop_scope.end)
2003        .and_then(|bindings| bindings.last())
2004    {
2005        let name = binding.name;
2006        if path.len() == name.len() && path == name {
2007            return Some(Cow::Borrowed(binding.value));
2008        }
2009        if path.len() > name.len()
2010            && path.as_bytes().get(name.len()) == Some(&b'.')
2011            && path.starts_with(name)
2012        {
2013            if let Some(value) =
2014                find_value_by_dotted_path_ref(&path[name.len() + 1..], binding.value)
2015            {
2016                return Some(value);
2017            }
2018            return find_value_by_dotted_path_ref(path, state);
2019        }
2020    }
2021
2022    if let Some(first_part) = path.split('.').next() {
2023        if let Some(local_value) = local_values.borrowed.get(first_part) {
2024            if first_part.len() == path.len() {
2025                return Some(Cow::Borrowed(local_value));
2026            }
2027            let remaining = &path[first_part.len() + 1..];
2028            if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
2029                return Some(value);
2030            }
2031            return find_value_by_dotted_path_ref(path, state);
2032        }
2033        if let Some(local_value) = local_values.owned.get(first_part) {
2034            if first_part.len() == path.len() {
2035                return Some(Cow::Borrowed(local_value));
2036            }
2037            let remaining = &path[first_part.len() + 1..];
2038            if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
2039                return Some(value);
2040            }
2041            return find_value_by_dotted_path_ref(path, state);
2042        }
2043
2044        for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2045            .iter()
2046            .rev()
2047        {
2048            if binding.name != first_part {
2049                continue;
2050            }
2051            if first_part.len() == path.len() {
2052                return Some(Cow::Borrowed(binding.value));
2053            }
2054            let remaining = &path[first_part.len() + 1..];
2055            if let Some(value) = find_value_by_dotted_path_ref(remaining, binding.value) {
2056                return Some(value);
2057            }
2058            return find_value_by_dotted_path_ref(path, state);
2059        }
2060    }
2061
2062    find_value_by_dotted_path_ref(path, state)
2063}
2064
2065fn resolve_borrowed_collection<'state>(
2066    path: &str,
2067    loop_vars: &[LoopBinding<'_, 'state>],
2068    visible_loop_scope: VisibleLoopScope,
2069    local_values: LocalValueSources<'_, '_, 'state>,
2070    state: &'state Value,
2071) -> Option<&'state [Value]> {
2072    let first_part = path.split('.').next()?;
2073    if let Some(binding) = loop_vars
2074        .get(visible_loop_scope.start..visible_loop_scope.end)
2075        .and_then(|bindings| bindings.last())
2076        .filter(|binding| binding.name == first_part)
2077    {
2078        if first_part.len() == path.len() {
2079            return binding.value.as_array().map(Vec::as_slice);
2080        }
2081        let remaining = &path[first_part.len() + 1..];
2082        return match find_value_by_dotted_path_ref(remaining, binding.value) {
2083            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2084            Some(Cow::Owned(_)) => None,
2085            None => borrowed_state_array(path, state),
2086        };
2087    }
2088    if let Some(local_value) = local_values.borrowed.get(first_part) {
2089        let local_match = if first_part.len() == path.len() {
2090            Some(Cow::Borrowed(local_value))
2091        } else {
2092            find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
2093        };
2094        return match local_match {
2095            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2096            Some(Cow::Owned(_)) => None,
2097            None => borrowed_state_array(path, state),
2098        };
2099    }
2100    if let Some(local_value) = local_values.owned.get(first_part) {
2101        let local_match = if first_part.len() == path.len() {
2102            Some(Cow::Borrowed(local_value))
2103        } else {
2104            find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
2105        };
2106        if local_match.is_some() {
2107            return None;
2108        }
2109        return borrowed_state_array(path, state);
2110    }
2111
2112    for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2113        .iter()
2114        .rev()
2115    {
2116        if binding.name != first_part {
2117            continue;
2118        }
2119        if first_part.len() == path.len() {
2120            return binding.value.as_array().map(Vec::as_slice);
2121        }
2122        let remaining = &path[first_part.len() + 1..];
2123        return match find_value_by_dotted_path_ref(remaining, binding.value) {
2124            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2125            Some(Cow::Owned(_)) => None,
2126            None => borrowed_state_array(path, state),
2127        };
2128    }
2129
2130    borrowed_state_array(path, state)
2131}
2132
2133fn resolve_state_backed_value<'state>(
2134    path: &str,
2135    loop_vars: &[LoopBinding<'_, 'state>],
2136    visible_loop_scope: VisibleLoopScope,
2137    local_values: LocalValueSources<'_, '_, 'state>,
2138    state: &'state Value,
2139) -> Option<&'state Value> {
2140    let first_part = path.split('.').next()?;
2141    if let Some(binding) = loop_vars
2142        .get(visible_loop_scope.start..visible_loop_scope.end)
2143        .and_then(|bindings| bindings.last())
2144        .filter(|binding| binding.name == first_part)
2145    {
2146        if first_part.len() == path.len() {
2147            return Some(binding.value);
2148        }
2149        let remaining = &path[first_part.len() + 1..];
2150        return match find_value_by_dotted_path_ref(remaining, binding.value) {
2151            Some(Cow::Borrowed(value)) => Some(value),
2152            Some(Cow::Owned(_)) => None,
2153            None => borrowed_state_value(path, state),
2154        };
2155    }
2156    if let Some(local_value) = local_values.borrowed.get(first_part) {
2157        if first_part.len() == path.len() {
2158            return Some(local_value);
2159        }
2160        return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
2161            Some(Cow::Borrowed(value)) => Some(value),
2162            Some(Cow::Owned(_)) => None,
2163            None => borrowed_state_value(path, state),
2164        };
2165    }
2166    if let Some(local_value) = local_values.owned.get(first_part) {
2167        if first_part.len() == path.len() {
2168            return None;
2169        }
2170        return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
2171            Some(_) => None,
2172            None => borrowed_state_value(path, state),
2173        };
2174    }
2175
2176    for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2177        .iter()
2178        .rev()
2179    {
2180        if binding.name != first_part {
2181            continue;
2182        }
2183        if first_part.len() == path.len() {
2184            return Some(binding.value);
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)) => Some(value),
2189            Some(Cow::Owned(_)) => None,
2190            None => borrowed_state_value(path, state),
2191        };
2192    }
2193
2194    borrowed_state_value(path, state)
2195}
2196
2197fn borrowed_state_value<'state>(path: &str, state: &'state Value) -> Option<&'state Value> {
2198    match find_value_by_dotted_path_ref(path, state) {
2199        Some(Cow::Borrowed(value)) => Some(value),
2200        Some(Cow::Owned(_)) | None => None,
2201    }
2202}
2203
2204fn borrowed_state_array<'state>(path: &str, state: &'state Value) -> Option<&'state [Value]> {
2205    match find_value_by_dotted_path_ref(path, state)? {
2206        Cow::Borrowed(value) => value.as_array().map(Vec::as_slice),
2207        Cow::Owned(_) => None,
2208    }
2209}
2210
2211impl WebUIHandler {
2212    /// Create a new WebUI handler with no plugin.
2213    pub fn new() -> Self {
2214        Self {
2215            plugin_factory: None,
2216        }
2217    }
2218
2219    /// Create a new WebUI handler with a plugin factory.
2220    ///
2221    /// Each render call creates a fresh plugin instance from the factory,
2222    /// enabling concurrent renders with `&self`.
2223    pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
2224        Self {
2225            plugin_factory: Some(factory),
2226        }
2227    }
2228
2229    #[cfg(test)]
2230    fn handle(
2231        &self,
2232        document: &WebUIProtocol,
2233        state: &Value,
2234        options: &RenderOptions<'_>,
2235        writer: &mut dyn ResponseWriter,
2236    ) -> Result<()> {
2237        let protocol = Protocol::new(document.clone());
2238        self.render(&protocol, state, options, writer)
2239    }
2240
2241    /// Process a fragment by its prepared render slot, falling back to an ID
2242    /// lookup when the caller has no prepared slot (handler-generated hosts).
2243    ///
2244    /// The `context` parameter contains scope-local variables that are accessible during rendering,
2245    /// such as loop iteration variables. This is separate from the global `state`.
2246    fn process_fragment_target<'data>(
2247        &self,
2248        target: Option<usize>,
2249        fragment_id: &str,
2250        context: &mut WebUIProcessContext<'data, '_, '_>,
2251    ) -> Result<()> {
2252        let Some(index) = target.or_else(|| context.render_fragments.index(fragment_id)) else {
2253            return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2254        };
2255        let Some(fragment_list) = context.render_fragments.list(index) else {
2256            return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2257        };
2258        self.process_fragment(fragment_list, context)
2259    }
2260
2261    /// Process a vector of fragments.
2262    ///
2263    /// The `context` maintains scope-specific variables that can be accessed by fragments
2264    /// during rendering, while `state` contains the global application state.
2265    fn process_fragment<'data>(
2266        &self,
2267        fragment_list: RenderFragmentList<'data>,
2268        context: &mut WebUIProcessContext<'data, '_, '_>,
2269    ) -> Result<()> {
2270        self.process_fragment_from(fragment_list, 0, context)
2271    }
2272
2273    fn process_fragment_from<'data>(
2274        &self,
2275        fragment_list: RenderFragmentList<'data>,
2276        start: usize,
2277        context: &mut WebUIProcessContext<'data, '_, '_>,
2278    ) -> Result<()> {
2279        let fragments = fragment_list.fragments;
2280        // Pre-scan: find the best matching route among sibling routes by specificity.
2281        // This ensures `/contacts/add` (2 literals) beats `/contacts/:id` (1 literal).
2282        // Resolves relative paths (`./`) using the current route_base.
2283        // Lists prepared without any route fragment cannot produce a match, so
2284        // the scan is skipped entirely for them.
2285        let best_route = if fragment_list.has_routes {
2286            route_renderer::find_best_route_match(
2287                fragments,
2288                context.request_path,
2289                &context.route_base,
2290                context.route_index,
2291            )
2292        } else {
2293            None
2294        };
2295        self.process_fragment_range(fragment_list, start..fragments.len(), &best_route, context)
2296    }
2297
2298    fn process_fragment_range<'data>(
2299        &self,
2300        fragment_list: RenderFragmentList<'data>,
2301        range: std::ops::Range<usize>,
2302        best_route: &Option<(String, route_matcher::RouteMatch)>,
2303        context: &mut WebUIProcessContext<'data, '_, '_>,
2304    ) -> Result<()> {
2305        let fragments = fragment_list.fragments;
2306        let Some(selected) = fragments.get(range.clone()) else {
2307            return Err(invalid_fragment_range_error(&range, fragments.len()));
2308        };
2309        for (offset, item) in selected.iter().enumerate() {
2310            let index = range.start + offset;
2311            if context.streaming.is_some() {
2312                validate_pending_streaming_root(item, context)?;
2313                validate_streaming_root_opening(&fragments[..index], item)?;
2314            }
2315            // The prepared target is only read by fragments that descend, so it
2316            // is resolved per-arm. Hoisting it here would charge every raw text
2317            // fragment for a lookup it never uses.
2318            match item.fragment.as_ref() {
2319                Some(Fragment::Raw(raw)) => {
2320                    context.writer.write(&raw.value)?;
2321                }
2322                Some(Fragment::Component(component)) => {
2323                    self.process_component(
2324                        &component.fragment_id,
2325                        fragment_list.target(index),
2326                        ComponentHostOrigin::ParserProduced,
2327                        context,
2328                    )?;
2329                }
2330                Some(Fragment::ForLoop(for_loop)) => {
2331                    self.process_for_loop(for_loop, fragment_list.target(index), context)?;
2332                }
2333                Some(Fragment::Signal(signal)) => {
2334                    self.process_signal(signal, context)?;
2335                }
2336                Some(Fragment::IfCond(if_cond)) => {
2337                    self.process_if(if_cond, fragment_list.target(index), context)?;
2338                }
2339                Some(Fragment::Attribute(attr)) => {
2340                    self.process_attribute(
2341                        attr,
2342                        fragment_list.target(index),
2343                        fragment_list.component_attr_name(index),
2344                        context,
2345                    )?;
2346                }
2347                Some(Fragment::Plugin(plugin_frag)) => {
2348                    if let Some(p) = &mut context.plugin {
2349                        p.on_element_data(&plugin_frag.data, context.writer)?;
2350                    }
2351                }
2352                Some(Fragment::Route(route_frag)) => {
2353                    self.process_route(route_frag, best_route, context)?;
2354                }
2355                Some(Fragment::Outlet(_)) => {
2356                    self.process_outlet(context)?;
2357                }
2358                Some(Fragment::Boundary(_)) => {}
2359                None => {}
2360            }
2361        }
2362        ensure_no_pending_streaming_root(context, "the end of the containing fragment")
2363    }
2364
2365    /// Process an `<outlet />` directive.
2366    ///
2367    /// Matches children from the currently active route's `children` field
2368    /// against the request path, renders the matched child `<webui-route>`
2369    /// elements directly at this position (no wrapper element).
2370    fn process_outlet<'protocol>(
2371        &self,
2372        context: &mut WebUIProcessContext<'protocol, '_, '_>,
2373    ) -> Result<()> {
2374        // Moved out so the matched child can render with the context pointing
2375        // at its grandchildren. The level is deliberately not put back, which
2376        // preserves the previous behavior exactly: a second `<outlet />` at
2377        // this level renders nothing. That is a latent bug tracked by #515, not
2378        // a property this function needs; fixing it belongs in its own change.
2379        let mut children = std::mem::take(&mut context.route_children);
2380        if children.is_empty() {
2381            return Ok(());
2382        }
2383
2384        // Find the best matching child route
2385        let request_segments = route_matcher::split_request_path(context.request_path);
2386        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
2387        for (idx, child) in children.iter().enumerate() {
2388            if let Some(m) = route_matcher::match_route_indexed_with_segments(
2389                context.route_index,
2390                &child.path,
2391                &context.route_base,
2392                &request_segments,
2393                child.exact,
2394            ) {
2395                let is_better = best
2396                    .as_ref()
2397                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
2398                if is_better {
2399                    best = Some((idx, m));
2400                }
2401            }
2402        }
2403
2404        let after_match = best.as_ref().map_or(0, |(idx, _)| idx + 1);
2405        if let Some((idx, ref rm)) = best {
2406            route_renderer::write_hidden_routes(context.writer, &children[..idx])?;
2407            let descended = descend_into(&mut children, idx);
2408            let Some(matched_child) = children.get(idx) else {
2409                return Ok(());
2410            };
2411            let comp = &matched_child.fragment_id;
2412
2413            if !comp.is_empty() {
2414                let saved_route_base = (rm.consumed_segments > 0).then(|| {
2415                    let base = route_matcher::compute_route_base(
2416                        context.request_path,
2417                        rm.consumed_segments,
2418                    );
2419                    std::mem::replace(&mut context.route_base, Cow::Owned(base))
2420                });
2421                context.route_children = descended;
2422
2423                // Emit matched <webui-route>
2424                context.writer.write("<webui-route")?;
2425                context.writer.write(" path=\"")?;
2426                context.writer.write(&matched_child.path)?;
2427                context.writer.write("\"")?;
2428                context.writer.write(" component=\"")?;
2429                context.writer.write(comp)?;
2430                context.writer.write("\"")?;
2431                if matched_child.exact {
2432                    context.writer.write(" exact")?;
2433                }
2434                route_renderer::write_route_navigation_attrs(context.writer, matched_child)?;
2435                // Emit data-ri for O(1) client-side element binding
2436                let ri = context.route_chain_index;
2437                context.route_chain_index += 1;
2438                context.writer.write(" data-ri=\"")?;
2439                write_usize(context.writer, ri)?;
2440                context.writer.write("\" active>")?;
2441
2442                if !Self::component_owns_css_tree(comp, context.protocol) {
2443                    self.emit_component_style_closure(comp, StyleClosureInstall::Routed, context)?;
2444                }
2445                if !matched_child.content_fragment_id.is_empty() {
2446                    self.process_fragment_target(
2447                        None,
2448                        &matched_child.content_fragment_id,
2449                        context,
2450                    )?;
2451                }
2452
2453                context.writer.write("<")?;
2454                context.writer.write(comp)?;
2455                if let Some(p) = &context.plugin {
2456                    p.write_route_component_state(context.state, context.writer)?;
2457                }
2458                write_interaction_marker(comp, context)?;
2459                prepare_generated_streaming_root(comp, context)?;
2460                context.writer.write(">")?;
2461
2462                self.process_component(comp, None, ComponentHostOrigin::HandlerGenerated, context)?;
2463
2464                context.writer.write("</")?;
2465                context.writer.write(comp)?;
2466                context.writer.write(">")?;
2467                context.writer.write("</webui-route>")?;
2468
2469                if let Some(saved) = saved_route_base {
2470                    context.route_base = saved;
2471                }
2472                // Restores the empty level the matched child was rendered
2473                // against, rather than the level this outlet matched. See the
2474                // note at the top of this function and #515.
2475                context.route_children = Cow::Borrowed(&[]);
2476            }
2477        }
2478
2479        route_renderer::write_hidden_routes(context.writer, &children[after_match..])
2480    }
2481
2482    /// Emit a `<script type="importmap">` tag that registers a component's
2483    /// CSS module under its specifier via a `data:text/css,…` URI.
2484    ///
2485    /// Requires Multiple Import Maps (Chrome 133+); each call emits an
2486    /// independent importmap that the browser merges at the document
2487    /// level. The per-render CSP nonce is applied when set (importmap
2488    /// scripts honor `script-src`).
2489    ///
2490    /// Example for `my-comp` with CSS `span{color:blue;}`:
2491    /// `<script type="importmap" nonce="..." data-webui-resource="my-comp">{"imports":{"my-comp":"data:text/css,span{color:blue;}"}}</script>`
2492    fn emit_css_module_importmap(
2493        &self,
2494        specifier: &str,
2495        css: &str,
2496        context: &mut WebUIProcessContext,
2497    ) -> Result<()> {
2498        let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
2499        context.writer.write(&tag)?;
2500        Ok(())
2501    }
2502
2503    /// Install one compiler-ordered closure into the active CSS tree.
2504    fn emit_component_style_closure(
2505        &self,
2506        root: &str,
2507        install: StyleClosureInstall,
2508        context: &mut WebUIProcessContext,
2509    ) -> Result<()> {
2510        if context.protocol.style_closures.is_empty() {
2511            return Ok(());
2512        }
2513        let shadow_static_closure = if install == StyleClosureInstall::Routed {
2514            match context.shadow_style_roots.last() {
2515                Some(shadow_root) => {
2516                    if !shadow_root.static_closure_emitted {
2517                        return Err(HandlerError::Invariant(
2518                            "routed styles reached a ShadowRoot before its static style hook"
2519                                .to_string(),
2520                        ));
2521                    }
2522                    let root_name = context
2523                        .component_index
2524                        .iter()
2525                        .find_map(|(name, index)| {
2526                            (*index == shadow_root.component_index).then_some(name.as_str())
2527                        })
2528                        .ok_or_else(|| {
2529                            HandlerError::Invariant(
2530                                "active Shadow style root lost its protocol index".to_string(),
2531                            )
2532                        })?;
2533                    Some(
2534                        context
2535                            .protocol
2536                            .style_closures
2537                            .get(root_name)
2538                            .ok_or_else(|| {
2539                                HandlerError::Invariant(format!(
2540                            "component style closure metadata is missing Shadow root `{root_name}`"
2541                        ))
2542                            })?,
2543                    )
2544                }
2545                None => None,
2546            }
2547        } else {
2548            None
2549        };
2550        let closure = context.protocol.style_closures.get(root).ok_or_else(|| {
2551            HandlerError::Invariant(format!(
2552                "component style closure metadata is missing root `{root}`"
2553            ))
2554        })?;
2555        let is_document_tree = context.shadow_style_roots.is_empty();
2556        let strategy = context.css_strategy;
2557
2558        // A bundled build delivers merged chunks; otherwise every component
2559        // delivers its own stylesheet. Both walk the closure in the same order,
2560        // so the emitted cascade is identical either way. Bundling is a
2561        // build-wide decision, so the two never mix within one protocol.
2562        let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2563        let mut emitted_resources = HashSet::with_capacity(unit_count);
2564
2565        for position in 0..unit_count {
2566            let unit = context
2567                .protocol
2568                .style_closure_unit(closure, &context.style_chunk_index, position)
2569                .ok_or_else(|| {
2570                    HandlerError::Invariant(format!(
2571                        "component style closure `{root}` references out-of-range unit {position}"
2572                    ))
2573                })?;
2574            let (name, chunk) = (unit.name, unit.chunk);
2575            if !emitted_resources.insert(name) {
2576                continue;
2577            }
2578            let resource = unit.resource.ok_or_else(|| match chunk {
2579                Some(index) => HandlerError::Invariant(format!(
2580                    "component style closure `{root}` references missing style chunk {index}"
2581                )),
2582                None => HandlerError::Invariant(format!(
2583                    "component style closure `{root}` references missing resource `{name}`"
2584                )),
2585            })?;
2586            if is_document_tree && !context.document_style_resources.insert(name.to_string()) {
2587                continue;
2588            }
2589            if let Some(static_closure) = shadow_static_closure {
2590                let resource_index = match chunk {
2591                    Some(index) => {
2592                        if static_closure.style_chunks.contains(&index) {
2593                            continue;
2594                        }
2595                        index
2596                    }
2597                    None => {
2598                        if static_closure.component_tags.iter().any(|tag| tag == name) {
2599                            continue;
2600                        }
2601                        context.component_index.get(name).copied().ok_or_else(|| {
2602                            HandlerError::Invariant(format!(
2603                                "component style resource `{name}` is missing its protocol index"
2604                            ))
2605                        })?
2606                    }
2607                };
2608                let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2609                    HandlerError::Invariant(
2610                        "active Shadow style root disappeared during routed style delivery"
2611                            .to_string(),
2612                    )
2613                })?;
2614                if shadow_root.routed_resources.contains(&resource_index) {
2615                    continue;
2616                }
2617                shadow_root.routed_resources.push(resource_index);
2618            }
2619
2620            match strategy {
2621                webui_protocol::CssStrategy::Link => {
2622                    context.writer.write("<link rel=\"stylesheet\" href=\"")?;
2623                    context
2624                        .writer
2625                        .write(&crate::html_encode::encode_safe(resource))?;
2626                    context.writer.write("\" data-webui-resource=\"")?;
2627                    context
2628                        .writer
2629                        .write(&crate::html_encode::encode_safe(name))?;
2630                    context.writer.write("\" data-webui-strategy=\"link\">")?;
2631                }
2632                strategy => {
2633                    context.writer.write("<style")?;
2634                    if let Some(nonce) = context.nonce {
2635                        context.writer.write(" nonce=\"")?;
2636                        context
2637                            .writer
2638                            .write(&crate::html_encode::encode_safe(nonce))?;
2639                        context.writer.write("\"")?;
2640                    }
2641                    context.writer.write(" data-webui-resource=\"")?;
2642                    context
2643                        .writer
2644                        .write(&crate::html_encode::encode_safe(name))?;
2645                    context.writer.write("\" data-webui-strategy=\"")?;
2646                    context
2647                        .writer
2648                        .write(if strategy == webui_protocol::CssStrategy::Module {
2649                            "module"
2650                        } else {
2651                            "style"
2652                        })?;
2653                    context.writer.write("\">")?;
2654                    if context.style_resources_requiring_escape.contains(name) {
2655                        crate::html_encode::write_style_text(context.writer, resource)?;
2656                    } else {
2657                        context.writer.write(resource)?;
2658                    }
2659                    context.writer.write("</style>")?;
2660                }
2661            }
2662        }
2663        Ok(())
2664    }
2665
2666    pub(crate) fn ensure_request_route_chain(context: &mut WebUIProcessContext) {
2667        if context.route_chain.is_none() {
2668            let plan = crate::route_handler::collect_route_chain_plan(
2669                context.protocol,
2670                context.entry_id,
2671                context.request_path,
2672                context.route_index,
2673            );
2674            context.route_document_style_targets = plan.document_style_targets;
2675            context.route_chain = Some(plan.entries);
2676        }
2677    }
2678
2679    fn emit_active_route_styles(
2680        &self,
2681        preloaded: &mut Vec<u32>,
2682        context: &mut WebUIProcessContext,
2683    ) -> Result<()> {
2684        Self::ensure_request_route_chain(context);
2685        let Some(chain) = context.route_chain.take() else {
2686            return Err(route_style_plan_missing_error());
2687        };
2688        let document_targets = std::mem::take(&mut context.route_document_style_targets);
2689        if chain.len() != document_targets.len() {
2690            let error = route_style_plan_length_error(chain.len(), document_targets.len());
2691            context.route_document_style_targets = document_targets;
2692            context.route_chain = Some(chain);
2693            return Err(error);
2694        }
2695
2696        let result = (|| {
2697            for (entry, targets_document) in chain.iter().zip(&document_targets) {
2698                if *targets_document {
2699                    self.emit_component_style_closure(
2700                        &entry.component,
2701                        StyleClosureInstall::Static,
2702                        context,
2703                    )?;
2704                    continue;
2705                }
2706
2707                if context.css_strategy != webui_protocol::CssStrategy::Link {
2708                    continue;
2709                }
2710                self.emit_component_style_preloads(&entry.component, preloaded, context)?;
2711            }
2712            Ok(())
2713        })();
2714
2715        context.route_document_style_targets = document_targets;
2716        context.route_chain = Some(chain);
2717        result
2718    }
2719
2720    fn emit_component_style_preloads(
2721        &self,
2722        root: &str,
2723        preloaded: &mut Vec<u32>,
2724        context: &mut WebUIProcessContext,
2725    ) -> Result<()> {
2726        let Some(closure) = context.protocol.style_closures.get(root) else {
2727            return Ok(());
2728        };
2729        // A chunk shared by several tree-local roots needs one preload.
2730        let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2731        for position in 0..unit_count {
2732            let Some(unit) =
2733                context
2734                    .protocol
2735                    .style_closure_unit(closure, &context.style_chunk_index, position)
2736            else {
2737                continue;
2738            };
2739            let Some(href) = unit.resource else {
2740                continue;
2741            };
2742            let name = unit.name;
2743            if context.document_style_resources.contains(name) {
2744                continue;
2745            }
2746            let Some(&resource_index) = context.style_resource_index.get(name) else {
2747                continue;
2748            };
2749            if preloaded.contains(&resource_index) {
2750                continue;
2751            }
2752            context
2753                .writer
2754                .write("<link rel=\"preload\" as=\"style\" href=\"")?;
2755            context
2756                .writer
2757                .write(&crate::html_encode::encode_safe(href))?;
2758            context.writer.write("\">")?;
2759            preloaded.push(resource_index);
2760        }
2761        Ok(())
2762    }
2763
2764    fn emit_reachable_shadow_preloads(
2765        &self,
2766        preloaded: &mut Vec<u32>,
2767        context: &mut WebUIProcessContext,
2768    ) -> Result<()> {
2769        if context.css_strategy != webui_protocol::CssStrategy::Link {
2770            return Ok(());
2771        }
2772        let reachable = context.reachable_components.take().unwrap_or_else(|| {
2773            crate::route_handler::collect_reachable_component_order_for_request(
2774                context.protocol,
2775                context.entry_id,
2776                context.request_path,
2777                context.route_index,
2778            )
2779        });
2780        let result = (|| {
2781            for component in &reachable {
2782                if context.protocol.component_uses_shadow_dom(component) {
2783                    self.emit_component_style_preloads(component, preloaded, context)?;
2784                }
2785            }
2786            Ok(())
2787        })();
2788        context.reachable_components = Some(reachable);
2789        result
2790    }
2791
2792    fn process_shadow_style_signal(
2793        &self,
2794        root: &str,
2795        context: &mut WebUIProcessContext,
2796    ) -> Result<()> {
2797        let Some(root_index) = context.component_index.get(root).copied() else {
2798            return Err(HandlerError::Invariant(format!(
2799                "Shadow style hook `{root}` references an unknown component"
2800            )));
2801        };
2802        if context
2803            .shadow_style_roots
2804            .last()
2805            .map(|shadow_root| shadow_root.component_index)
2806            != Some(root_index)
2807        {
2808            return Err(HandlerError::Invariant(format!(
2809                "Shadow style hook `{root}` does not match the active component root"
2810            )));
2811        }
2812        self.emit_component_style_closure(root, StyleClosureInstall::Static, context)?;
2813        let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2814            HandlerError::Invariant(
2815                "active Shadow style root disappeared after its static style hook".to_string(),
2816            )
2817        })?;
2818        shadow_root.static_closure_emitted = true;
2819        Ok(())
2820    }
2821
2822    /// Emit a component's CSS module importmap on its first render
2823    /// (deduped by `rendered_components`) into the component's light DOM,
2824    /// so the browser registers it under the component's specifier
2825    /// before the shadow root template is parsed. See
2826    /// [`Self::emit_css_module_importmap`] for the emitted shape.
2827    ///
2828    /// Only components rendered on the current route get inline definitions;
2829    /// navigation responses carry later definitions in `componentStyles`.
2830    fn emit_css_module(&self, fragment_id: &str, context: &mut WebUIProcessContext) -> Result<()> {
2831        if context.css_strategy != webui_protocol::CssStrategy::Module {
2832            return Ok(());
2833        }
2834        let metadata_already_streamed = context.streaming.as_ref().is_some_and(|streaming| {
2835            streaming_template_already_sent(streaming, context.component_index, fragment_id)
2836        });
2837        if !metadata_already_streamed && !context.rendered_components.contains(fragment_id) {
2838            if let Some(css) = context
2839                .protocol
2840                .components
2841                .get(fragment_id)
2842                .map(|c| c.css.as_str())
2843                .filter(|s| !s.is_empty())
2844            {
2845                self.emit_css_module_importmap(fragment_id, css, context)?;
2846            }
2847        }
2848        Ok(())
2849    }
2850
2851    /// Process a route fragment — renders `<webui-route>` with matched/hidden state.
2852    fn process_route<'protocol>(
2853        &self,
2854        route_frag: &'protocol webui_protocol::WebUiFragmentRoute,
2855        best_route: &Option<(String, route_matcher::RouteMatch)>,
2856        context: &mut WebUIProcessContext<'protocol, '_, '_>,
2857    ) -> Result<()> {
2858        let is_matched = best_route
2859            .as_ref()
2860            .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
2861
2862        context.writer.write("<webui-route")?;
2863        context.writer.write(" path=\"")?;
2864        context.writer.write(&route_frag.path)?;
2865        context.writer.write("\"")?;
2866        if !route_frag.fragment_id.is_empty() {
2867            context.writer.write(" component=\"")?;
2868            context.writer.write(&route_frag.fragment_id)?;
2869            context.writer.write("\"")?;
2870        }
2871        if route_frag.exact {
2872            context.writer.write(" exact")?;
2873        }
2874        route_renderer::write_route_navigation_attrs(context.writer, route_frag)?;
2875
2876        if is_matched {
2877            // Emit data-ri for O(1) client-side element binding
2878            let ri = context.route_chain_index;
2879            context.route_chain_index += 1;
2880            context.writer.write(" data-ri=\"")?;
2881            write_usize(context.writer, ri)?;
2882            context.writer.write("\" active>")?;
2883
2884            let saved_route_base = best_route.as_ref().map(|(_, rm)| {
2885                let base =
2886                    route_matcher::compute_route_base(context.request_path, rm.consumed_segments);
2887                std::mem::replace(&mut context.route_base, Cow::Owned(base))
2888            });
2889            let saved_route_children = std::mem::replace(
2890                &mut context.route_children,
2891                Cow::Borrowed(&route_frag.children),
2892            );
2893
2894            if !route_frag.content_fragment_id.is_empty() {
2895                self.process_fragment_target(None, &route_frag.content_fragment_id, context)?;
2896            }
2897
2898            if !route_frag.fragment_id.is_empty() {
2899                if !Self::component_owns_css_tree(&route_frag.fragment_id, context.protocol) {
2900                    self.emit_component_style_closure(
2901                        &route_frag.fragment_id,
2902                        StyleClosureInstall::Routed,
2903                        context,
2904                    )?;
2905                }
2906                context.writer.write("<")?;
2907                context.writer.write(&route_frag.fragment_id)?;
2908                if let Some(p) = &context.plugin {
2909                    p.write_route_component_state(context.state, context.writer)?;
2910                }
2911                write_interaction_marker(&route_frag.fragment_id, context)?;
2912                prepare_generated_streaming_root(&route_frag.fragment_id, context)?;
2913                context.writer.write(">")?;
2914
2915                self.process_component(
2916                    &route_frag.fragment_id,
2917                    None,
2918                    ComponentHostOrigin::HandlerGenerated,
2919                    context,
2920                )?;
2921
2922                context.writer.write("</")?;
2923                context.writer.write(&route_frag.fragment_id)?;
2924                context.writer.write(">")?;
2925            }
2926            if let Some(saved) = saved_route_base {
2927                context.route_base = saved;
2928            }
2929            context.route_children = saved_route_children;
2930        } else {
2931            context.writer.write(" style=\"display:none\">")?;
2932        }
2933
2934        context.writer.write("</webui-route>")?;
2935        Ok(())
2936    }
2937
2938    /// Process a component fragment.
2939    fn process_component(
2940        &self,
2941        fragment_id: &str,
2942        target: Option<usize>,
2943        origin: ComponentHostOrigin,
2944        context: &mut WebUIProcessContext,
2945    ) -> Result<()> {
2946        if context.streaming.is_some() {
2947            consume_streaming_component_root(fragment_id, origin, context)?;
2948            // Capture only after root parity succeeds, so malformed protocols
2949            // cannot contribute unmarked hosts to a checkpoint.
2950            record_checkpoint_tag(context, fragment_id);
2951        }
2952
2953        // Emit the component's CSS module importmap into its light DOM and track
2954        // the component as rendered on first encounter only. `rendered_components`
2955        // is a set, so gating the `insert` (and its `String` clone) behind the
2956        // first-encounter check avoids allocating a throwaway `String` for every
2957        // duplicate instance while keeping the set contents identical.
2958        if !context.rendered_components.contains(fragment_id) {
2959            self.emit_css_module(fragment_id, context)?;
2960            context.rendered_components.insert(fragment_id.to_string());
2961        }
2962
2963        let owns_css_tree = Self::component_owns_css_tree(fragment_id, context.protocol);
2964        if owns_css_tree {
2965            Self::push_shadow_style_root(fragment_id, context)?;
2966        }
2967
2968        // Save parent scope. `mem::take` leaves an alloc-free empty map behind.
2969        let saved_local_vars = std::mem::take(&mut context.local_vars);
2970        let saved_local_borrowed_vars = std::mem::take(&mut context.local_borrowed_vars);
2971        // The component's accumulated attrs become its local vars; the next
2972        // sibling accumulates into a recycled (capacity-preserving) map from the
2973        // request-local pool instead of a freshly allocated `HashMap`.
2974        let saved_component_attrs = std::mem::replace(
2975            &mut context.component_attrs,
2976            take_scope_map(&mut context.scope_pool),
2977        );
2978        let saved_component_borrowed_attrs = std::mem::replace(
2979            &mut context.component_borrowed_attrs,
2980            take_borrowed_scope(&mut context.borrowed_scope_pool),
2981        );
2982        context.local_vars = saved_component_attrs;
2983        context.local_borrowed_vars = saved_component_borrowed_attrs;
2984        context.collecting_component_attrs = false;
2985        let saved_loop_scope = context.visible_loop_scope;
2986        context.visible_loop_scope = VisibleLoopScope {
2987            start: context.loop_vars.len(),
2988            end: context.loop_vars.len(),
2989        };
2990
2991        if let Some(p) = &mut context.plugin {
2992            p.push_scope();
2993        }
2994
2995        let render_result = self.process_fragment_target(target, fragment_id, context);
2996        context.visible_loop_scope = saved_loop_scope;
2997
2998        if owns_css_tree {
2999            Self::pop_shadow_style_root(fragment_id, context)?;
3000        }
3001        render_result?;
3002
3003        if let Some(p) = &mut context.plugin {
3004            p.pop_scope();
3005        }
3006
3007        // Restore parent scope, recycling this component's local map (its
3008        // accumulated attrs) back into the pool so a sibling reuses its capacity.
3009        let used_locals = std::mem::replace(&mut context.local_vars, saved_local_vars);
3010        recycle_scope_map(&mut context.scope_pool, used_locals);
3011        let used_borrowed_locals =
3012            std::mem::replace(&mut context.local_borrowed_vars, saved_local_borrowed_vars);
3013        recycle_borrowed_scope(&mut context.borrowed_scope_pool, used_borrowed_locals);
3014        // The attr accumulator (pulled from the pool above) is cleared for the
3015        // next sibling while retaining its bucket capacity.
3016        context.component_attrs.clear();
3017        context.component_borrowed_attrs.clear();
3018
3019        Ok(())
3020    }
3021
3022    #[inline]
3023    pub(crate) fn component_owns_css_tree(component: &str, protocol: &WebUIProtocol) -> bool {
3024        !protocol.style_closures.is_empty()
3025            && protocol
3026                .components
3027                .get(component)
3028                .is_some_and(|data| data.uses_shadow_dom)
3029    }
3030
3031    pub(crate) fn push_shadow_style_root(
3032        component: &str,
3033        context: &mut WebUIProcessContext,
3034    ) -> Result<()> {
3035        if !context.protocol.style_closures.contains_key(component) {
3036            return Err(HandlerError::Invariant(format!(
3037                "component style closure metadata is missing Shadow root `{component}`"
3038            )));
3039        }
3040        let root_index = context
3041            .component_index
3042            .get(component)
3043            .copied()
3044            .ok_or_else(|| {
3045                HandlerError::Invariant(format!(
3046                    "Shadow component `{component}` is missing its protocol index"
3047                ))
3048            })?;
3049        context.shadow_style_roots.push(ShadowStyleRoot {
3050            component_index: root_index,
3051            static_closure_emitted: false,
3052            routed_resources: Vec::new(),
3053        });
3054        Ok(())
3055    }
3056
3057    pub(crate) fn pop_shadow_style_root(
3058        component: &str,
3059        context: &mut WebUIProcessContext,
3060    ) -> Result<()> {
3061        context.shadow_style_roots.pop().ok_or_else(|| {
3062            HandlerError::Invariant(format!(
3063                "Shadow component `{component}` lost its active style root"
3064            ))
3065        })?;
3066        Ok(())
3067    }
3068
3069    /// Resolve a dotted path into owned state for values retained by the context.
3070    fn resolve_value_owned(
3071        &self,
3072        path: &str,
3073        context: &WebUIProcessContext<'_, '_, '_>,
3074    ) -> Option<Value> {
3075        resolve_value_from_sources(
3076            path,
3077            &context.loop_vars,
3078            context.visible_loop_scope,
3079            LocalValueSources {
3080                owned: &context.local_vars,
3081                borrowed: &context.local_borrowed_vars,
3082            },
3083            context.state,
3084        )
3085        .map(Cow::into_owned)
3086    }
3087
3088    /// Evaluate a condition expression against the current context.
3089    ///
3090    /// Uses a resolver closure that checks local variables first, then falls
3091    /// back to global state — avoiding a full clone of the state tree.
3092    /// Missing identifier operands are falsy before logical operators are applied.
3093    /// Missing predicate values make the complete condition false.
3094    fn evaluate_condition(
3095        &self,
3096        condition: &webui_protocol::ConditionExpr,
3097        context: &WebUIProcessContext,
3098    ) -> Result<bool> {
3099        let loop_vars = &context.loop_vars;
3100        let visible_loop_scope = context.visible_loop_scope;
3101        let local_values = LocalValueSources {
3102            owned: &context.local_vars,
3103            borrowed: &context.local_borrowed_vars,
3104        };
3105        let state = context.state;
3106        match evaluate_with_resolver(condition, |path| {
3107            resolve_value_from_sources(path, loop_vars, visible_loop_scope, local_values, state)
3108        }) {
3109            Ok(result) => Ok(result),
3110            Err(ExpressionError::MissingValue(_)) => Ok(false),
3111            Err(e) => Err(HandlerError::Evaluation(e.to_string())),
3112        }
3113    }
3114
3115    /// Process a for loop fragment.
3116    ///
3117    /// Creates a new context for each iteration that includes the current loop item.
3118    /// This allows nested templates to access both the loop variable and any parent context.
3119    /// Example: `for item in items` makes "item" available in the loop body.
3120    fn process_for_loop<'protocol, 'state>(
3121        &self,
3122        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3123        target: Option<usize>,
3124        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3125    ) -> Result<()> {
3126        if let Some(items) = resolve_borrowed_collection(
3127            &for_loop.collection,
3128            &context.loop_vars,
3129            context.visible_loop_scope,
3130            LocalValueSources {
3131                owned: &context.local_vars,
3132                borrowed: &context.local_borrowed_vars,
3133            },
3134            context.state,
3135        ) {
3136            return self.process_borrowed_for_loop(for_loop, target, items, context);
3137        }
3138        self.process_owned_for_loop(for_loop, target, context)
3139    }
3140
3141    fn process_borrowed_for_loop<'protocol, 'state>(
3142        &self,
3143        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3144        target: Option<usize>,
3145        items: &'state [Value],
3146        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3147    ) -> Result<()> {
3148        if let Some(plugin) = &mut context.plugin {
3149            plugin.on_for_start(&for_loop.fragment_id, context.writer)?;
3150        }
3151
3152        let item_name = for_loop.item.as_str();
3153        let saved_value = context.local_vars.remove(item_name);
3154        let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3155        let saved_scope = context.visible_loop_scope;
3156        for (index, item) in items.iter().enumerate() {
3157            if let Some(plugin) = &mut context.plugin {
3158                plugin.on_repeat_item_start(index, context.writer)?;
3159                plugin.push_scope();
3160            }
3161
3162            context.loop_vars.push(LoopBinding {
3163                name: item_name,
3164                value: item,
3165            });
3166            context.visible_loop_scope.end = context.loop_vars.len();
3167            self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3168            context.loop_vars.pop();
3169            context.visible_loop_scope = saved_scope;
3170
3171            if let Some(plugin) = &mut context.plugin {
3172                plugin.pop_scope();
3173                plugin.on_repeat_item_end(index, context.writer)?;
3174            }
3175        }
3176        if let Some(value) = saved_value {
3177            context.local_vars.insert(item_name.to_string(), value);
3178        }
3179        if let Some(value) = saved_borrowed_value {
3180            context.local_borrowed_vars.insert(item_name, value);
3181        }
3182
3183        if let Some(plugin) = &mut context.plugin {
3184            plugin.on_for_end(&for_loop.fragment_id, context.writer)?;
3185        }
3186        Ok(())
3187    }
3188
3189    fn process_owned_for_loop<'protocol, 'state>(
3190        &self,
3191        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3192        target: Option<usize>,
3193        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3194    ) -> Result<()> {
3195        let collection_name = &for_loop.collection;
3196
3197        // If the collection is missing, treat it as empty (0 iterations) — matches NodeJS behavior.
3198        // Hydration comments are always emitted regardless of collection presence.
3199        let items = match self.resolve_value_owned(collection_name, context) {
3200            Some(Value::Array(arr)) => arr,
3201            Some(_) => {
3202                return Err(HandlerError::TypeError(format!(
3203                    "Collection '{}' is not an array",
3204                    collection_name
3205                )))
3206            }
3207            None => Vec::new(),
3208        };
3209
3210        if let Some(p) = &mut context.plugin {
3211            p.on_for_start(&for_loop.fragment_id, context.writer)?;
3212        }
3213
3214        // Hot-loop optimisation: the loop variable name is `String`-keyed
3215        // in `local_vars`. The naive impl re-inserts (and so re-allocates
3216        // the key) on every iteration — a 1000-item loop pays 2000 String
3217        // clones for the key alone. Instead, we save the outer-scope
3218        // value (if any) ONCE before the loop, install the key ONCE with
3219        // an empty placeholder, then overwrite the value in-place each
3220        // iteration via `get_mut`. Restoration at the end happens once.
3221        let item_name = for_loop.item.as_str();
3222        let saved_value = context.local_vars.remove(item_name);
3223        let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3224        // Pre-insert the key so per-iteration `get_mut` is infallible.
3225        // Cost: at most one `String::from(item_name)` for the lifetime
3226        // of the loop, regardless of iteration count.
3227        if !items.is_empty() {
3228            context
3229                .local_vars
3230                .insert(item_name.to_string(), Value::Null);
3231        }
3232        for (i, item) in items.into_iter().enumerate() {
3233            if let Some(p) = &mut context.plugin {
3234                p.on_repeat_item_start(i, context.writer)?;
3235                p.push_scope();
3236            }
3237
3238            // O(1) value swap; no key allocation.
3239            if let Some(slot) = context.local_vars.get_mut(item_name) {
3240                *slot = item;
3241            }
3242            self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3243
3244            if let Some(p) = &mut context.plugin {
3245                p.pop_scope();
3246                p.on_repeat_item_end(i, context.writer)?;
3247            }
3248        }
3249        // Restore outer scope (or remove the placeholder we installed).
3250        match saved_value {
3251            Some(value) => {
3252                context.local_vars.insert(item_name.to_string(), value);
3253            }
3254            None => {
3255                context.local_vars.remove(item_name);
3256            }
3257        }
3258        if let Some(value) = saved_borrowed_value {
3259            context.local_borrowed_vars.insert(item_name, value);
3260        }
3261
3262        if let Some(p) = &mut context.plugin {
3263            p.on_for_end(&for_loop.fragment_id, context.writer)?;
3264        }
3265
3266        Ok(())
3267    }
3268
3269    /// Process a signal fragment.
3270    ///
3271    /// Looks up the value in the context first (for local variables), then in the global state.
3272    /// This prioritization allows local variables (like loop items) to override global state.
3273    /// If the value is not found in either scope, an empty string is returned.
3274    #[inline]
3275    fn process_signal<'data>(
3276        &self,
3277        signal: &'data webui_protocol::WebUIFragmentSignal,
3278        context: &mut WebUIProcessContext<'data, '_, '_>,
3279    ) -> Result<()> {
3280        if signal.raw {
3281            self.process_raw_signal(signal, context)
3282        } else {
3283            self.process_state_signal(signal, false, context)
3284        }
3285    }
3286
3287    #[inline(never)]
3288    fn process_raw_signal<'data>(
3289        &self,
3290        signal: &'data webui_protocol::WebUIFragmentSignal,
3291        context: &mut WebUIProcessContext<'data, '_, '_>,
3292    ) -> Result<()> {
3293        let Some(structural_value) = structural_signal_value(signal) else {
3294            return self.process_state_signal(signal, !signal.raw_text_context, context);
3295        };
3296
3297        if let Some(root) = structural_value.strip_prefix("shadow_styles:") {
3298            return self.process_shadow_style_signal(root, context);
3299        }
3300
3301        if context.streaming.is_some()
3302            && self.process_streaming_signal(structural_value, context)?
3303        {
3304            return Ok(());
3305        }
3306
3307        // Hook: emit nonce meta and Document-owned CSS before </head>.
3308        // Guarded by `head_end_emitted` so a malformed protocol cannot
3309        // emit nonce/preloads/inject more than once per render.
3310        if structural_value == "head_end" && !context.head_end_emitted {
3311            context.head_end_emitted = true;
3312            if let Some(nonce) = context.nonce {
3313                context
3314                    .writer
3315                    .write("<meta name=\"webui-nonce\" content=\"")?;
3316                context
3317                    .writer
3318                    .write(&crate::html_encode::encode_safe(nonce))?;
3319                context.writer.write("\">")?;
3320            }
3321
3322            // Render-policy CSS is emitted before component styles so an
3323            // authored declaration still wins on a tie.
3324            if !context.protocol.component_render_css.is_empty() {
3325                context.writer.write("<style data-webui-render-policy")?;
3326                if let Some(nonce) = context.nonce {
3327                    context.writer.write(" nonce=\"")?;
3328                    context
3329                        .writer
3330                        .write(&crate::html_encode::encode_safe(nonce))?;
3331                    context.writer.write("\"")?;
3332                }
3333                context.writer.write(">")?;
3334                context
3335                    .writer
3336                    .write(&context.protocol.component_render_css)?;
3337                context.writer.write("</style>")?;
3338            }
3339
3340            if !context.protocol.style_closures.is_empty() {
3341                let entry_id = context.entry_id;
3342                self.emit_component_style_closure(entry_id, StyleClosureInstall::Static, context)?;
3343                let mut preloaded = Vec::new();
3344                self.emit_active_route_styles(&mut preloaded, context)?;
3345                self.emit_reachable_shadow_preloads(&mut preloaded, context)?;
3346            }
3347
3348            // Compiler-resolved `modulepreload` hints for the shared chunks
3349            // the page's module entries statically import. Those chunks are
3350            // named only inside the entry's own bytes, so without this the
3351            // browser must download and parse the entry before it can even
3352            // discover them.
3353            //
3354            // Emitted after the CSS links on purpose: CSS is render-blocking
3355            // and owns first paint, while these own first interaction, so the
3356            // stylesheet requests go out first. The list arrives pre-ordered
3357            // (largest chunk first) and pre-resolved from the build, so this
3358            // is a straight write with no per-request work.
3359            if !context.protocol.module_preloads.is_empty() {
3360                for href in &context.protocol.module_preloads {
3361                    context
3362                        .writer
3363                        .write("<link rel=\"modulepreload\" href=\"")?;
3364                    context.writer.write(href)?;
3365                    context.writer.write("\">")?;
3366                }
3367            }
3368
3369            write_component_asset_styles(context)?;
3370
3371            // Per-render `head_inject` HTML — image preloads, A/B test
3372            // markers, etc. supplied by the host via RenderOptions.
3373            // Emitted at the structural head_end boundary, after the
3374            // built-in nonce + CSS-link emissions, so host injects
3375            // appear immediately before `</head>`.
3376            if let Some(html) = context.head_inject {
3377                context.writer.write(html)?;
3378            }
3379
3380            // Reserved-state `headEnd` HTML, last at this boundary so a
3381            // host that sets both channels gets a deterministic order:
3382            // built-in emissions, then `RenderOptions`, then state.
3383            if let Some(html) = context.state_inject.head_end {
3384                context.writer.write(html)?;
3385            }
3386        }
3387
3388        // Hook: emit state-supplied HTML immediately after `<body>`. Guarded
3389        // by its own dedup flag so a malformed protocol cannot duplicate it.
3390        if structural_value == "body_start" && !context.body_start_emitted {
3391            context.body_start_emitted = true;
3392            write_component_asset_styles(context)?;
3393            if let Some(html) = context.state_inject.body_start {
3394                context.writer.write(html)?;
3395            }
3396        }
3397
3398        // Hook: emit component templates and host body_inject before </body>.
3399        // Single guarded block so the dedup flag protects both the
3400        // hydration emission and the host inject from a malformed
3401        // protocol that fires `body_end` more than once per render.
3402        if structural_value == "body_end" && !context.body_end_emitted {
3403            context.body_end_emitted = true;
3404            if context.plugin.is_some() {
3405                // Emit templates for all REACHABLE components on the current route,
3406                // not just those rendered in this SSR pass. Components inside false
3407                // <if> blocks or empty <for> loops are reachable via client-side
3408                // state changes and need their templates available without a server
3409                // round-trip. The graph walker follows conditional and loop branches
3410                // unconditionally, but only descends into the matched route chain —
3411                // components on other routes are delivered via SPA partial navigation.
3412                // Kept as the traversal-ordered `Vec` produced upstream (already
3413                // deduplicated) rather than collected into a `HashSet`: downstream
3414                // `<head>` CSS `<link>`/style-module emission order must stay
3415                // deterministic across renders, not vary with the process's
3416                // randomized hash seed.
3417                let reachable = context.reachable_components.take().unwrap_or_else(|| {
3418                    crate::route_handler::collect_reachable_component_order_for_request(
3419                        context.protocol,
3420                        context.entry_id,
3421                        context.request_path,
3422                        context.route_index,
3423                    )
3424                });
3425                let state_selection =
3426                    collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
3427
3428                // Emit CSS module importmaps for reachable-but-unrendered
3429                // components so the framework can adopt them when an `<if>`
3430                // condition flips true client-side.
3431                if context.css_strategy == webui_protocol::CssStrategy::Module {
3432                    for name in &reachable {
3433                        if !context.rendered_components.contains(name) {
3434                            if let Some(css) = context
3435                                .protocol
3436                                .components
3437                                .get(name)
3438                                .map(|c| c.css.as_str())
3439                                .filter(|s| !s.is_empty())
3440                            {
3441                                self.emit_css_module_importmap(name, css, context)?;
3442                            }
3443                        }
3444                    }
3445                }
3446
3447                // Try to collect split WebUI template payloads. If the plugin
3448                // returns None (non-WebUI templates, e.g. FAST), fall back to
3449                // separate emission.
3450                let template_payloads = context
3451                    .plugin
3452                    .as_ref()
3453                    .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
3454
3455                if template_payloads.is_none() {
3456                    // Non-JS templates (FAST plugins) - emit separately
3457                    if let Some(ref p) = context.plugin {
3458                        p.emit_templates(
3459                            context.protocol,
3460                            &reachable,
3461                            context.nonce,
3462                            context.writer,
3463                        )?;
3464                    }
3465                }
3466
3467                // Compute the inventory hex from actually rendered components.
3468                let inventory_hex = crate::route_handler::encode_component_inventory(
3469                    &context.rendered_components,
3470                    context.component_index,
3471                );
3472
3473                // Chain
3474                Self::ensure_request_route_chain(context);
3475                let chain = context.route_chain.as_deref().ok_or_else(|| {
3476                    HandlerError::Invariant(
3477                        "request route chain disappeared after collection".to_string(),
3478                    )
3479                })?;
3480                let chain_json: Vec<Value> = chain
3481                    .iter()
3482                    .map(crate::route_handler::RouteChainEntry::to_json)
3483                    .collect();
3484
3485                // CSS hrefs emitted during SSR (Link-strategy components)
3486                let is_link = context.css_strategy == webui_protocol::CssStrategy::Link;
3487                let mut css_hrefs: Vec<&str> = Vec::new();
3488                if is_link {
3489                    for name in &reachable {
3490                        if let Some(href) = context
3491                            .protocol
3492                            .components
3493                            .get(name)
3494                            .map(|c| c.css_href.as_str())
3495                            .filter(|h| !h.is_empty())
3496                        {
3497                            css_hrefs.push(href);
3498                        }
3499                    }
3500                }
3501
3502                // Module style specifiers emitted during SSR
3503                let mut style_specs: Vec<&str> = Vec::new();
3504                if context.css_strategy == webui_protocol::CssStrategy::Module {
3505                    for name in &reachable {
3506                        if context
3507                            .protocol
3508                            .components
3509                            .get(name)
3510                            .map(|c| !c.css.is_empty())
3511                            .unwrap_or(false)
3512                        {
3513                            style_specs.push(name);
3514                        }
3515                    }
3516                }
3517
3518                let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
3519                let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
3520                let mut style_roots = Vec::with_capacity(reachable.len() + chain.len() * 3 + 1);
3521                style_roots.push(context.entry_id);
3522                for entry in chain {
3523                    style_roots.push(entry.component.as_str());
3524                    if !entry.pending_component.is_empty() {
3525                        style_roots.push(entry.pending_component.as_str());
3526                    }
3527                    if !entry.error_component.is_empty() {
3528                        style_roots.push(entry.error_component.as_str());
3529                    }
3530                }
3531                style_roots.extend(reachable.iter().map(String::as_str));
3532                let component_styles =
3533                    crate::route_handler::collect_component_styles(context.protocol, style_roots)?;
3534                write_webui_data_block(
3535                    context.writer,
3536                    &mut context.json_scratch,
3537                    WebUiBootstrap {
3538                        declaration_id: None,
3539                        enclosing_span_instance_id: None,
3540                        state: WebUiBootstrapState::Complete {
3541                            value: context.state,
3542                            selection: state_selection,
3543                        },
3544                        chain: &chain_json,
3545                        inventory: &inventory_hex,
3546                        nonce: context.nonce,
3547                        css_hrefs: &css_hrefs,
3548                        style_specs: &style_specs,
3549                        component_styles: &component_styles,
3550                        templates: payloads,
3551                    },
3552                )?;
3553
3554                // Let the active plugin emit any framework-specific executable
3555                // side channel. FAST plugins default to no-op; WebUI installs
3556                // templateFns. Client packages parse #webui-data lazily.
3557                if let Some(ref plugin) = context.plugin {
3558                    plugin.emit_bootstrap_extension(
3559                        BootstrapExtensionContext {
3560                            protocol: context.protocol,
3561                            components: &reachable,
3562                            payloads,
3563                            nonce: context.nonce,
3564                        },
3565                        context.writer,
3566                    )?;
3567                }
3568            }
3569
3570            // Per-render `body_inject` HTML — dev livereload script,
3571            // analytics, etc. supplied by the host via RenderOptions.
3572            // Inside the dedup block but outside the plugin-only
3573            // sub-block above, so it fires regardless of whether a
3574            // hydration plugin is active. Appears immediately before
3575            // `</body>`.
3576            if let Some(html) = context.body_inject {
3577                context.writer.write(html)?;
3578            }
3579
3580            // Reserved-state `bodyEnd` HTML, last at this boundary. Same
3581            // precedence as `head_end`: built-ins, `RenderOptions`, state.
3582            if let Some(html) = context.state_inject.body_end {
3583                context.writer.write(html)?;
3584            }
3585        }
3586
3587        // Structural signals are never state lookups. In particular, ordinary
3588        // rendering ignores boundary/root markers byte-for-byte, while authored
3589        // raw bindings with the same visible key remain ordinary state.
3590        Ok(())
3591    }
3592
3593    /// Process a plain (non-structural) signal by resolving its value and
3594    /// writing it to the response, HTML-encoded unless `signal.raw` is set.
3595    ///
3596    /// `owns_html_range` only controls whether the active plugin emits
3597    /// replaceable sibling markers around the value (`on_binding_start/end`);
3598    /// it never affects escaping. In particular, `signal.raw_text_context`
3599    /// (marker ownership inside `<style>`/`<script>`/etc.) is independent of
3600    /// `signal.raw` (escaping): a signal can be marker-free *and* HTML-encoded
3601    /// at the same time. See `WebUIFragment::raw_text_signal` for why an
3602    /// escaped binding inside an HTML raw-text element is an authoring
3603    /// footgun rather than something this function can safely correct.
3604    #[inline]
3605    fn process_state_signal(
3606        &self,
3607        signal: &webui_protocol::WebUIFragmentSignal,
3608        owns_html_range: bool,
3609        context: &mut WebUIProcessContext,
3610    ) -> Result<()> {
3611        if let Some(p) = &mut context.plugin {
3612            p.on_binding_start(&signal.value, owns_html_range, context.writer)?;
3613        }
3614
3615        if let Some(value) = resolve_value_from_sources(
3616            &signal.value,
3617            &context.loop_vars,
3618            context.visible_loop_scope,
3619            LocalValueSources {
3620                owned: &context.local_vars,
3621                borrowed: &context.local_borrowed_vars,
3622            },
3623            context.state,
3624        ) {
3625            self.write_signal_value(value.as_ref(), signal.raw, context.writer)?;
3626        }
3627
3628        if let Some(p) = &mut context.plugin {
3629            p.on_binding_end(&signal.value, owns_html_range, context.writer)?;
3630        }
3631        Ok(())
3632    }
3633
3634    /// Write a signal value directly to the writer, avoiding intermediate String allocation.
3635    /// For HTML-escaped output, writes the Cow from `encode_safe` directly.
3636    ///
3637    /// `raw` here is purely the authored escaping choice (`{{value}}` vs.
3638    /// `{{{value}}}`) and is applied uniformly regardless of surrounding HTML
3639    /// context: it does not know whether it is writing into a raw-text
3640    /// element (`<script>`, `<style>`, `<xmp>`, which never decode character
3641    /// references) or an RCDATA element (`<title>`, `<textarea>`, which do).
3642    fn write_signal_value(
3643        &self,
3644        value: &Value,
3645        raw: bool,
3646        writer: &mut dyn ResponseWriter,
3647    ) -> Result<()> {
3648        // Numbers, booleans, and null render the same escaped or not, and never
3649        // need a heap buffer to reach the writer.
3650        if write_plain_json_scalar(writer, value)? {
3651            return Ok(());
3652        }
3653        if raw {
3654            match value {
3655                Value::String(s) => writer.write(s),
3656                _ => writer.write(&value.to_string()),
3657            }
3658        } else {
3659            match value {
3660                Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
3661                _ => {
3662                    let s = value.to_string();
3663                    writer.write(&crate::html_encode::encode_safe(&s))
3664                }
3665            }
3666        }
3667    }
3668
3669    /// Process an if condition fragment.
3670    fn process_if(
3671        &self,
3672        if_cond: &webui_protocol::WebUIFragmentIf,
3673        target: Option<usize>,
3674        context: &mut WebUIProcessContext,
3675    ) -> Result<()> {
3676        let condition = if_cond
3677            .condition
3678            .as_ref()
3679            .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
3680        let condition_met = self.evaluate_condition(condition, context)?;
3681
3682        if let Some(p) = &mut context.plugin {
3683            p.on_if_start(&if_cond.fragment_id, context.writer)?;
3684        }
3685
3686        if condition_met {
3687            if let Some(p) = &mut context.plugin {
3688                p.push_scope();
3689            }
3690
3691            self.process_fragment_target(target, &if_cond.fragment_id, context)?;
3692
3693            if let Some(p) = &mut context.plugin {
3694                p.pop_scope();
3695            }
3696        }
3697
3698        if let Some(p) = &mut context.plugin {
3699            p.on_if_end(&if_cond.fragment_id, context.writer)?;
3700        }
3701
3702        Ok(())
3703    }
3704
3705    /// Process an attribute fragment by rendering the attribute name/value pair.
3706    ///
3707    /// `template_target` and `component_name` are prepared once per protocol by
3708    /// [`RenderFragmentIndex`], so neither the template fragment lookup nor the
3709    /// camelCase prop-name conversion is repeated per render.
3710    fn process_attribute<'protocol, 'state>(
3711        &self,
3712        attr: &'protocol webui_protocol::WebUIFragmentAttribute,
3713        template_target: Option<usize>,
3714        component_name: Option<&'protocol str>,
3715        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3716    ) -> Result<()> {
3717        // Initialize component attribute accumulator on attrStart. Clearing the
3718        // pooled map keeps its bucket capacity instead of allocating a fresh one.
3719        if attr.attr_start {
3720            context.component_attrs.clear();
3721            context.component_borrowed_attrs.clear();
3722            context.collecting_component_attrs = true;
3723        }
3724
3725        // Boolean attribute with condition tree
3726        if let Some(condition) = &attr.condition_tree {
3727            let condition_met = self.evaluate_condition(condition, context)?;
3728
3729            if context.collecting_component_attrs && !attr.attr_skip {
3730                let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3731                context.component_borrowed_attrs.remove(name);
3732                context
3733                    .component_attrs
3734                    .insert(name.to_owned(), Value::Bool(condition_met));
3735            }
3736
3737            if condition_met {
3738                context.writer.write(" ")?;
3739                context.writer.write(&attr.name)?;
3740            }
3741            return Ok(());
3742        }
3743
3744        // Template attribute (mixed static + dynamic)
3745        if !attr.template.is_empty() {
3746            let raw_value =
3747                self.render_template_attr_value(&attr.template, template_target, context)?;
3748            let escaped = crate::html_encode::encode_safe(&raw_value);
3749            write_attr(context.writer, &attr.name, &escaped)?;
3750
3751            if context.collecting_component_attrs && !attr.attr_skip {
3752                let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3753                context.component_borrowed_attrs.remove(name);
3754                context
3755                    .component_attrs
3756                    .insert(name.to_owned(), Value::String(raw_value));
3757            }
3758            return Ok(());
3759        }
3760
3761        // Simple attribute
3762        if attr.raw_value || !attr.value.is_empty() {
3763            if attr.raw_value {
3764                // Static attribute — value is the literal string
3765                write_attr(
3766                    context.writer,
3767                    &attr.name,
3768                    &crate::html_encode::encode_safe(&attr.value),
3769                )?;
3770                if context.collecting_component_attrs && !attr.attr_skip {
3771                    let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3772                    context.component_borrowed_attrs.remove(name);
3773                    context
3774                        .component_attrs
3775                        .insert(name.to_owned(), Value::String(attr.value.clone()));
3776                }
3777            } else if attr.complex {
3778                // Complex attribute — resolve value, don't render to HTML, store as state
3779                if context.collecting_component_attrs && !attr.attr_skip {
3780                    // Streaming starts borrowed too. The continuation VM
3781                    // materializes these values only when the target component
3782                    // can actually suspend; boundary-free components finish in
3783                    // this call and retain the ordinary zero-copy path.
3784                    let state_backed_value = resolve_state_backed_value(
3785                        &attr.value,
3786                        &context.loop_vars,
3787                        context.visible_loop_scope,
3788                        LocalValueSources {
3789                            owned: &context.local_vars,
3790                            borrowed: &context.local_borrowed_vars,
3791                        },
3792                        context.state,
3793                    );
3794                    if let Some(value) = state_backed_value {
3795                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3796                        context.component_attrs.remove(name);
3797                        context.component_borrowed_attrs.insert(name, value);
3798                    } else if let Some(value) = self.resolve_value_owned(&attr.value, context) {
3799                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3800                        context.component_borrowed_attrs.remove(name);
3801                        context.component_attrs.insert(name.to_owned(), value);
3802                    }
3803                }
3804            } else {
3805                // Dynamic attribute — resolve and render
3806                // As above, a boundary-bearing continuation materializes a
3807                // borrowed component scope before it can escape this call.
3808                let state_backed_value = if context.collecting_component_attrs && !attr.attr_skip {
3809                    resolve_state_backed_value(
3810                        &attr.value,
3811                        &context.loop_vars,
3812                        context.visible_loop_scope,
3813                        LocalValueSources {
3814                            owned: &context.local_vars,
3815                            borrowed: &context.local_borrowed_vars,
3816                        },
3817                        context.state,
3818                    )
3819                } else {
3820                    None
3821                };
3822                // Reuse the immutable-state lookup for both HTML output and the
3823                // child scope instead of resolving every component attribute twice.
3824                let value = match state_backed_value {
3825                    Some(value) => Some(Cow::Borrowed(value)),
3826                    None => resolve_value_from_sources(
3827                        &attr.value,
3828                        &context.loop_vars,
3829                        context.visible_loop_scope,
3830                        LocalValueSources {
3831                            owned: &context.local_vars,
3832                            borrowed: &context.local_borrowed_vars,
3833                        },
3834                        context.state,
3835                    ),
3836                };
3837                // Always emit the attribute so FAST hydration markers
3838                // (`data-fe`) match the DOM node structure.
3839                match value.as_deref() {
3840                    Some(Value::String(s)) => {
3841                        write_attr(
3842                            context.writer,
3843                            &attr.name,
3844                            &crate::html_encode::encode_safe(s),
3845                        )?;
3846                    }
3847                    Some(Value::Null) | None => {
3848                        write_attr(context.writer, &attr.name, "")?;
3849                    }
3850                    Some(other) => {
3851                        let mut scalar = ScalarBuffer::new();
3852                        match format_plain_json_scalar(&mut scalar, other) {
3853                            Some(rendered) => {
3854                                write_attr(context.writer, &attr.name, rendered)?;
3855                            }
3856                            None => {
3857                                let s = other.to_string();
3858                                write_attr(
3859                                    context.writer,
3860                                    &attr.name,
3861                                    &crate::html_encode::encode_safe(&s),
3862                                )?;
3863                            }
3864                        }
3865                    }
3866                }
3867
3868                if context.collecting_component_attrs && !attr.attr_skip {
3869                    if let Some(borrowed) = state_backed_value {
3870                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3871                        context.component_attrs.remove(name);
3872                        context.component_borrowed_attrs.insert(name, borrowed);
3873                    } else {
3874                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3875                        context.component_borrowed_attrs.remove(name);
3876                        context.component_attrs.insert(
3877                            name.to_owned(),
3878                            value
3879                                .map(Cow::into_owned)
3880                                .unwrap_or(Value::String(String::new())),
3881                        );
3882                    }
3883                }
3884            }
3885        }
3886
3887        Ok(())
3888    }
3889
3890    /// Render a template attribute's fragments into a raw (unescaped) string.
3891    fn render_template_attr_value(
3892        &self,
3893        template_id: &str,
3894        target: Option<usize>,
3895        context: &WebUIProcessContext,
3896    ) -> Result<String> {
3897        let Some(index) = target.or_else(|| context.render_fragments.index(template_id)) else {
3898            return Err(HandlerError::MissingFragment(template_id.to_string()));
3899        };
3900        let fragments = context
3901            .render_fragments
3902            .list(index)
3903            .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
3904        let mut raw_value = String::new();
3905        for frag in fragments.fragments {
3906            match frag.fragment.as_ref() {
3907                Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
3908                Some(Fragment::Signal(signal)) => {
3909                    if let Some(value) = resolve_value_from_sources(
3910                        &signal.value,
3911                        &context.loop_vars,
3912                        context.visible_loop_scope,
3913                        LocalValueSources {
3914                            owned: &context.local_vars,
3915                            borrowed: &context.local_borrowed_vars,
3916                        },
3917                        context.state,
3918                    ) {
3919                        match value.as_ref() {
3920                            Value::String(s) => raw_value.push_str(s),
3921                            other => {
3922                                let mut scalar = ScalarBuffer::new();
3923                                match format_plain_json_scalar(&mut scalar, other) {
3924                                    Some(rendered) => raw_value.push_str(rendered),
3925                                    None => raw_value.push_str(&other.to_string()),
3926                                }
3927                            }
3928                        }
3929                    }
3930                }
3931                _ => {}
3932            }
3933        }
3934        Ok(raw_value)
3935    }
3936
3937    /// Render the UI based on the protocol and state.
3938    pub fn render<'a>(
3939        &self,
3940        protocol: &'a Protocol,
3941        state: &'a Value,
3942        options: &RenderOptions<'a>,
3943        writer: &'a mut dyn ResponseWriter,
3944    ) -> Result<()> {
3945        protocol.ensure_style_metadata()?;
3946        let document = protocol.protocol();
3947        let render_fragments = protocol.render_fragments().resolve(document);
3948        let Some(entry_index) = render_fragments.index(options.entry_id) else {
3949            return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
3950        };
3951        let entry = render_fragments
3952            .list(entry_index)
3953            .ok_or_else(|| HandlerError::MissingFragment(options.entry_id.to_string()))?;
3954        let entry_owns_css_tree = Self::component_owns_css_tree(options.entry_id, document);
3955        let has_document_head_boundary = !entry_owns_css_tree
3956            && !document.style_closures.is_empty()
3957            && entry.fragments.iter().any(|fragment| {
3958                matches!(
3959                    fragment.fragment.as_ref(),
3960                    Some(Fragment::Signal(signal))
3961                        if structural_signal_value(signal) == Some("head_end")
3962                )
3963            });
3964        let doctype_split = (!entry_owns_css_tree
3965            && !has_document_head_boundary
3966            && !document.style_closures.is_empty())
3967        .then(|| {
3968            let Some(Fragment::Raw(raw)) = entry
3969                .fragments
3970                .first()
3971                .and_then(|fragment| fragment.fragment.as_ref())
3972            else {
3973                return None;
3974            };
3975            doctype_prefix_end(&raw.value).map(|end| (raw.value.as_str(), end))
3976        })
3977        .flatten();
3978        let component_asset_style_manifest = protocol.component_asset_style_manifest()?;
3979        let mut context = WebUIProcessContext {
3980            protocol: document,
3981            render_fragments,
3982            component_asset_style_manifest,
3983            component_asset_style_links: protocol.component_asset_style_links(),
3984            state,
3985            writer,
3986            local_vars: HashMap::new(),
3987            local_borrowed_vars: BorrowedScope::default(),
3988            loop_vars: Vec::new(),
3989            visible_loop_scope: VisibleLoopScope::EMPTY,
3990            component_attrs: HashMap::new(),
3991            component_borrowed_attrs: BorrowedScope::default(),
3992            collecting_component_attrs: false,
3993            request_path: options.request_path,
3994            route_base: Cow::Borrowed("/"),
3995            rendered_components: HashSet::new(),
3996            plugin: self.plugin_factory.map(|f| f()),
3997            route_children: Cow::Borrowed(&[]),
3998            entry_id: options.entry_id,
3999            // Same defensive normalisation as `handle()`. See the
4000            // doc-comment there for the CSP-outage rationale.
4001            nonce: options.nonce.filter(|s| !s.is_empty()),
4002            head_inject: options.head_inject.filter(|s| !s.is_empty()),
4003            body_inject: options.body_inject.filter(|s| !s.is_empty()),
4004            state_inject: StateInject::resolve(state),
4005            head_end_emitted: false,
4006            body_start_emitted: false,
4007            component_asset_styles_emitted: false,
4008            component_index: protocol.component_index(),
4009            style_resource_index: protocol.style_resource_index(),
4010            style_resources_requiring_escape: protocol.style_resources_requiring_escape(),
4011            style_chunk_index: protocol.protocol().style_chunk_index(),
4012            css_strategy: protocol.css_strategy(),
4013            body_end_emitted: false,
4014            route_index: protocol.route_index(),
4015            route_chain_index: 0,
4016            route_chain: None,
4017            route_document_style_targets: Vec::new(),
4018            reachable_components: None,
4019            streaming: None,
4020            json_scratch: Vec::new(),
4021            scope_pool: Vec::new(),
4022            document_style_resources: HashSet::new(),
4023            shadow_style_roots: Vec::new(),
4024            borrowed_scope_pool: Vec::new(),
4025        };
4026
4027        if entry_owns_css_tree {
4028            Self::push_shadow_style_root(options.entry_id, &mut context)?;
4029        }
4030
4031        let render_result = if let Some((first_raw, split)) = doctype_split {
4032            context.writer.write(&first_raw[..split])?;
4033            self.emit_component_style_closure(
4034                options.entry_id,
4035                StyleClosureInstall::Static,
4036                &mut context,
4037            )?;
4038            self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
4039            context.writer.write(&first_raw[split..])?;
4040            self.process_fragment_from(entry, 1, &mut context)
4041        } else {
4042            if !entry_owns_css_tree
4043                && !has_document_head_boundary
4044                && !document.style_closures.is_empty()
4045            {
4046                self.emit_component_style_closure(
4047                    options.entry_id,
4048                    StyleClosureInstall::Static,
4049                    &mut context,
4050                )?;
4051                self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
4052            }
4053            self.process_fragment(entry, &mut context)
4054        };
4055
4056        if entry_owns_css_tree {
4057            Self::pop_shadow_style_root(options.entry_id, &mut context)?;
4058        }
4059        render_result?;
4060        writer.end()?;
4061
4062        Ok(())
4063    }
4064}
4065
4066impl Default for WebUIHandler {
4067    fn default() -> Self {
4068        Self::new()
4069    }
4070}
4071
4072/// Write ` name="value"` to the writer without allocating a format string.
4073fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
4074    writer.write_attribute(name, value)
4075}
4076
4077#[cfg(test)]
4078fn handle(
4079    protocol: &WebUIProtocol,
4080    state: &Value,
4081    options: &RenderOptions<'_>,
4082    writer: &mut dyn ResponseWriter,
4083) -> Result<()> {
4084    let handler = WebUIHandler::new();
4085    handler.handle(protocol, state, options, writer)
4086}
4087
4088#[cfg(test)]
4089mod scalar_buffer_tests {
4090    use super::*;
4091
4092    /// Every scalar the stack formatter claims must serialize byte-identically
4093    /// to the `Value::to_string` path it replaced.
4094    #[test]
4095    fn plain_scalars_match_value_to_string() {
4096        let cases = [
4097            Value::Null,
4098            Value::Bool(true),
4099            Value::Bool(false),
4100            serde_json::json!(0),
4101            serde_json::json!(-1),
4102            serde_json::json!(42),
4103            serde_json::json!(i64::MIN),
4104            serde_json::json!(i64::MAX),
4105            serde_json::json!(u64::MAX),
4106            serde_json::json!(0.5),
4107            serde_json::json!(-0.0_f64),
4108            serde_json::json!(1.7976931348623157e308_f64),
4109            serde_json::json!(-1.7976931348623157e308_f64),
4110            serde_json::json!(f64::MIN_POSITIVE),
4111        ];
4112        for value in cases {
4113            let mut buffer = ScalarBuffer::new();
4114            let rendered = format_plain_json_scalar(&mut buffer, &value);
4115            assert_eq!(
4116                rendered,
4117                Some(value.to_string().as_str()),
4118                "scalar rendering diverged for {value:?}"
4119            );
4120        }
4121    }
4122
4123    /// Strings, arrays, and objects must fall through to the caller's escaping
4124    /// fallback rather than being emitted unescaped.
4125    #[test]
4126    fn non_plain_values_are_declined() {
4127        let cases = [
4128            serde_json::json!("<script>"),
4129            serde_json::json!([1, 2]),
4130            serde_json::json!({ "a": 1 }),
4131        ];
4132        for value in cases {
4133            let mut buffer = ScalarBuffer::new();
4134            assert_eq!(
4135                format_plain_json_scalar(&mut buffer, &value),
4136                None,
4137                "expected fallback for {value:?}"
4138            );
4139        }
4140    }
4141
4142    /// Overflow must be reported, never silently truncated into bad output.
4143    #[test]
4144    fn overflow_is_reported_instead_of_truncating() {
4145        let mut buffer = ScalarBuffer::new();
4146        assert!(write!(&mut buffer, "{}", "x".repeat(49)).is_err());
4147    }
4148}
4149
4150#[cfg(test)]
4151mod tests {
4152    use super::*;
4153    use crate::streaming::STREAMING_MARKER;
4154    use std::cell::RefCell;
4155    use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
4156    use webui_protocol::{
4157        web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
4158        WebUIFragmentAttribute, WebUiFragmentRoute,
4159    };
4160    use webui_test_utils::test_json;
4161
4162    fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
4163        WebUIFragment::signal(
4164            format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
4165            true,
4166        )
4167    }
4168
4169    #[test]
4170    fn route_descent_borrows_protocol_children() {
4171        let routes = vec![WebUiFragmentRoute {
4172            children: vec![WebUiFragmentRoute {
4173                fragment_id: "child".to_string(),
4174                ..Default::default()
4175            }],
4176            ..Default::default()
4177        }];
4178        let mut level = RouteChildren::Borrowed(&routes);
4179
4180        let descended = descend_into(&mut level, 0);
4181
4182        let Cow::Borrowed(children) = descended else {
4183            panic!("protocol-owned route children should remain borrowed");
4184        };
4185        assert!(std::ptr::eq(children, routes[0].children.as_slice()));
4186    }
4187
4188    #[test]
4189    fn route_descent_moves_parked_children() {
4190        let mut level = RouteChildren::Owned(vec![WebUiFragmentRoute {
4191            children: vec![WebUiFragmentRoute {
4192                fragment_id: "child".to_string(),
4193                ..Default::default()
4194            }],
4195            ..Default::default()
4196        }]);
4197
4198        let descended = descend_into(&mut level, 0);
4199
4200        let Cow::Owned(children) = descended else {
4201            panic!("parked route children must not borrow session-owned storage");
4202        };
4203        assert_eq!(children[0].fragment_id, "child");
4204        let Cow::Owned(source) = level else {
4205            panic!("the parked source level must stay owned");
4206        };
4207        assert!(
4208            source[0].children.is_empty(),
4209            "owned route children must move out instead of being cloned"
4210        );
4211    }
4212
4213    #[test]
4214    fn borrowed_collection_reuses_state_array() {
4215        let state = test_json!({
4216            "items": [
4217                {"name": "first"},
4218                {"name": "second"}
4219            ]
4220        });
4221        let local_vars = HashMap::new();
4222        let local_borrowed_vars = BorrowedScope::default();
4223        let loop_vars = Vec::new();
4224        let items = resolve_borrowed_collection(
4225            "items",
4226            &loop_vars,
4227            VisibleLoopScope::EMPTY,
4228            LocalValueSources {
4229                owned: &local_vars,
4230                borrowed: &local_borrowed_vars,
4231            },
4232            &state,
4233        )
4234        .unwrap_or_else(|| panic!("state array should be borrowed"));
4235        let state_items = state["items"]
4236            .as_array()
4237            .unwrap_or_else(|| panic!("fixture items should be an array"));
4238        assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4239    }
4240
4241    #[test]
4242    fn owned_local_collection_keeps_precedence() {
4243        let state = test_json!({"items": [{"name": "global"}]});
4244        let local_vars = HashMap::from([("items".to_string(), test_json!([{"name": "local"}]))]);
4245        let local_borrowed_vars = BorrowedScope::default();
4246        assert!(resolve_borrowed_collection(
4247            "items",
4248            &[],
4249            VisibleLoopScope::EMPTY,
4250            LocalValueSources {
4251                owned: &local_vars,
4252                borrowed: &local_borrowed_vars,
4253            },
4254            &state,
4255        )
4256        .is_none());
4257    }
4258
4259    #[test]
4260    fn borrowed_component_collection_reuses_state_array() {
4261        let state = test_json!({"teams": [{"contacts": [{"name": "Avery"}]}]});
4262        let contacts = &state["teams"][0]["contacts"];
4263        let mut local_borrowed_vars = BorrowedScope::default();
4264        local_borrowed_vars.insert("contacts", contacts);
4265        let local_vars = HashMap::new();
4266        let items = resolve_borrowed_collection(
4267            "contacts",
4268            &[],
4269            VisibleLoopScope::EMPTY,
4270            LocalValueSources {
4271                owned: &local_vars,
4272                borrowed: &local_borrowed_vars,
4273            },
4274            &state,
4275        )
4276        .unwrap_or_else(|| panic!("component collection should stay borrowed"));
4277        let state_items = contacts
4278            .as_array()
4279            .unwrap_or_else(|| panic!("fixture contacts should be an array"));
4280        assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4281    }
4282
4283    #[test]
4284    fn nested_borrowed_collection_reuses_child_array() {
4285        let state = test_json!({
4286            "items": [{
4287                "children": [
4288                    {"name": "first"},
4289                    {"name": "second"}
4290                ]
4291            }]
4292        });
4293        let item = &state["items"][0];
4294        let loop_vars = vec![LoopBinding {
4295            name: "item",
4296            value: item,
4297        }];
4298        let local_vars = HashMap::new();
4299        let local_borrowed_vars = BorrowedScope::default();
4300        let children = resolve_borrowed_collection(
4301            "item.children",
4302            &loop_vars,
4303            VisibleLoopScope { start: 0, end: 1 },
4304            LocalValueSources {
4305                owned: &local_vars,
4306                borrowed: &local_borrowed_vars,
4307            },
4308            &state,
4309        )
4310        .unwrap_or_else(|| panic!("nested state array should be borrowed"));
4311        let state_children = item["children"]
4312            .as_array()
4313            .unwrap_or_else(|| panic!("fixture children should be an array"));
4314        assert!(std::ptr::eq(children.as_ptr(), state_children.as_ptr()));
4315    }
4316
4317    #[test]
4318    fn borrowed_resolver_preserves_loop_precedence_and_component_isolation() {
4319        let state = test_json!({
4320            "item": {"name": "global", "fallback": "global fallback"},
4321            "outer": {"name": "outer"},
4322            "inner": {"name": "inner"}
4323        });
4324        let loop_vars = vec![
4325            LoopBinding {
4326                name: "item",
4327                value: &state["outer"],
4328            },
4329            LoopBinding {
4330                name: "item",
4331                value: &state["inner"],
4332            },
4333        ];
4334        let local_vars = HashMap::new();
4335        let local_borrowed_vars = BorrowedScope::default();
4336        let sources = LocalValueSources {
4337            owned: &local_vars,
4338            borrowed: &local_borrowed_vars,
4339        };
4340
4341        assert_eq!(
4342            resolve_value_from_sources(
4343                "item.name",
4344                &loop_vars,
4345                VisibleLoopScope { start: 0, end: 2 },
4346                sources,
4347                &state,
4348            )
4349            .as_deref()
4350            .and_then(Value::as_str),
4351            Some("inner")
4352        );
4353        assert_eq!(
4354            resolve_value_from_sources(
4355                "item.fallback",
4356                &loop_vars,
4357                VisibleLoopScope { start: 0, end: 2 },
4358                sources,
4359                &state,
4360            )
4361            .as_deref()
4362            .and_then(Value::as_str),
4363            Some("global fallback")
4364        );
4365        assert_eq!(
4366            resolve_value_from_sources(
4367                "item.name",
4368                &loop_vars,
4369                VisibleLoopScope { start: 2, end: 2 },
4370                sources,
4371                &state,
4372            )
4373            .as_deref()
4374            .and_then(Value::as_str),
4375            Some("global")
4376        );
4377    }
4378
4379    #[test]
4380    fn state_backed_resolver_matches_general_borrowed_resolution() {
4381        let state = test_json!({
4382            "global": {"name": "global"},
4383            "inner": {"fallback": "global fallback"},
4384            "borrowed": {"name": "borrowed"},
4385            "items": [1, 2, 3]
4386        });
4387        let loop_values = test_json!({
4388            "outer": {"name": "outer"},
4389            "inner": {"name": "inner"}
4390        });
4391        let loop_vars = vec![
4392            LoopBinding {
4393                name: "outer",
4394                value: &loop_values["outer"],
4395            },
4396            LoopBinding {
4397                name: "inner",
4398                value: &loop_values["inner"],
4399            },
4400        ];
4401        let mut local_borrowed_vars = BorrowedScope::default();
4402        local_borrowed_vars.insert("borrowed", &state["borrowed"]);
4403        let local_vars = HashMap::from([("owned".to_string(), test_json!({"name": "owned"}))]);
4404        let sources = LocalValueSources {
4405            owned: &local_vars,
4406            borrowed: &local_borrowed_vars,
4407        };
4408        let scope = VisibleLoopScope { start: 0, end: 2 };
4409
4410        for path in [
4411            "global.name",
4412            "inner.name",
4413            "inner.fallback",
4414            "outer.name",
4415            "borrowed.name",
4416        ] {
4417            let state_backed = resolve_state_backed_value(path, &loop_vars, scope, sources, &state)
4418                .unwrap_or_else(|| panic!("{path} should resolve from immutable state"));
4419            let resolved = resolve_value_from_sources(path, &loop_vars, scope, sources, &state)
4420                .unwrap_or_else(|| panic!("{path} should resolve generally"));
4421            let Cow::Borrowed(resolved) = resolved else {
4422                panic!("{path} should remain borrowed");
4423            };
4424            assert!(
4425                std::ptr::eq(state_backed, resolved),
4426                "{path} resolved from different sources"
4427            );
4428        }
4429
4430        assert!(
4431            resolve_state_backed_value("owned.name", &loop_vars, scope, sources, &state).is_none(),
4432            "owned local values must use the general resolver"
4433        );
4434        assert!(
4435            resolve_state_backed_value("items.length", &loop_vars, scope, sources, &state)
4436                .is_none(),
4437            "synthetic values must use the general resolver"
4438        );
4439    }
4440
4441    #[test]
4442    fn borrowed_scope_preserves_inline_overflow_and_replacement_semantics() {
4443        let values = [
4444            Value::String("a".to_string()),
4445            Value::String("b".to_string()),
4446            Value::String("c".to_string()),
4447            Value::String("d".to_string()),
4448            Value::String("e".to_string()),
4449            Value::String("f".to_string()),
4450            Value::String("replacement".to_string()),
4451        ];
4452        let mut scope = BorrowedScope::default();
4453        for (index, name) in ["a", "b", "c", "d", "e", "f"].iter().enumerate() {
4454            assert!(scope.insert(name, &values[index]).is_none());
4455        }
4456        assert_eq!(scope.inline_len, INLINE_SCOPE_SLOTS);
4457        assert_eq!(scope.overflow.len(), 2);
4458        assert_eq!(scope.get("a"), Some(&values[0]));
4459        assert_eq!(scope.get("f"), Some(&values[5]));
4460        assert_eq!(scope.insert("a", &values[6]), Some(&values[0]));
4461        assert_eq!(scope.get("a"), Some(&values[6]));
4462        assert_eq!(scope.remove("b"), Some(&values[1]));
4463        assert!(scope.get("b").is_none());
4464        assert_eq!(scope.get("f"), Some(&values[5]));
4465        scope.clear();
4466        assert!(scope.get("a").is_none());
4467        assert!(scope.overflow.is_empty());
4468    }
4469
4470    // A simple test writer implementation
4471    struct TestWriter {
4472        content: RefCell<String>,
4473        ended: RefCell<bool>,
4474    }
4475
4476    impl TestWriter {
4477        fn new() -> Self {
4478            Self {
4479                content: RefCell::new(String::new()),
4480                ended: RefCell::new(false),
4481            }
4482        }
4483
4484        fn get_content(&self) -> String {
4485            self.content.borrow().clone()
4486        }
4487
4488        fn is_ended(&self) -> bool {
4489            *self.ended.borrow()
4490        }
4491    }
4492
4493    impl ResponseWriter for TestWriter {
4494        fn write(&mut self, content: &str) -> Result<()> {
4495            self.content.borrow_mut().push_str(content);
4496            Ok(())
4497        }
4498
4499        fn end(&mut self) -> Result<()> {
4500            *self.ended.borrow_mut() = true;
4501            Ok(())
4502        }
4503    }
4504
4505    #[test]
4506    fn generated_string_writer_methods_preserve_exact_attribute_output() {
4507        struct AttributeWriter {
4508            output: String,
4509        }
4510
4511        impl ResponseWriter for AttributeWriter {
4512            fn write(&mut self, content: &str) -> Result<()> {
4513                self.output.push_str(content);
4514                Ok(())
4515            }
4516
4517            crate::string_response_writer_methods!(output);
4518
4519            fn end(&mut self) -> Result<()> {
4520                Ok(())
4521            }
4522        }
4523
4524        let mut writer = AttributeWriter {
4525            output: String::new(),
4526        };
4527        write_attr(&mut writer, "data-id", "42")
4528            .unwrap_or_else(|error| panic!("attribute write failed: {error}"));
4529        writer
4530            .write_boolean_attribute("disabled")
4531            .unwrap_or_else(|error| panic!("boolean attribute write failed: {error}"));
4532        assert_eq!(writer.output, " data-id=\"42\" disabled");
4533    }
4534
4535    #[test]
4536    fn prepared_graph_resolves_targets_and_canonical_component_prop_names() {
4537        let mut fragments = HashMap::new();
4538        fragments.insert(
4539            "index.html".to_string(),
4540            FragmentList {
4541                fragments: vec![
4542                    WebUIFragment {
4543                        fragment: Some(Fragment::Attribute(WebUIFragmentAttribute {
4544                            name: ":data-title".to_string(),
4545                            value: "source".to_string(),
4546                            complex: true,
4547                            attr_start: true,
4548                            ..Default::default()
4549                        })),
4550                    },
4551                    WebUIFragment::component("prepared-child"),
4552                ],
4553                contains_boundary: false,
4554            },
4555        );
4556        fragments.insert(
4557            "prepared-child".to_string(),
4558            FragmentList {
4559                fragments: vec![WebUIFragment::signal("dataTitle", false)],
4560                contains_boundary: false,
4561            },
4562        );
4563        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4564        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4565        let entry = resolved
4566            .list_by_id("index.html")
4567            .unwrap_or_else(|| panic!("entry render plan should exist"));
4568        let child_index = protocol
4569            .render_fragments()
4570            .index("prepared-child")
4571            .unwrap_or_else(|| panic!("component target should be indexed"));
4572        assert_eq!(entry.component_attr_name(0), Some("dataTitle"));
4573        assert_eq!(entry.target(1), Some(child_index));
4574        assert!(!entry.has_routes);
4575        let source_entry = protocol
4576            .protocol()
4577            .fragments
4578            .get("index.html")
4579            .unwrap_or_else(|| panic!("source entry should exist"));
4580        assert!(std::ptr::eq(
4581            entry.fragments.as_ptr(),
4582            source_entry.fragments.as_ptr()
4583        ));
4584
4585        let state = test_json!({"source": "Prepared"});
4586        let options = RenderOptions::new("index.html", "/");
4587        for handler in [
4588            WebUIHandler::new(),
4589            WebUIHandler::with_plugin(|| {
4590                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
4591            }),
4592        ] {
4593            let mut writer = TestWriter::new();
4594            assert!(handler
4595                .render(&protocol, &state, &options, &mut writer)
4596                .is_ok());
4597            assert!(writer.get_content().contains("Prepared"));
4598        }
4599    }
4600
4601    #[test]
4602    fn prepared_graph_records_route_presence_per_fragment_list() {
4603        let mut fragments = HashMap::new();
4604        fragments.insert(
4605            "index.html".to_string(),
4606            FragmentList {
4607                fragments: vec![WebUIFragment::route("/", "dash-page")],
4608                contains_boundary: false,
4609            },
4610        );
4611        fragments.insert(
4612            "dash-page".to_string(),
4613            FragmentList {
4614                fragments: vec![WebUIFragment::raw("dashboard")],
4615                contains_boundary: false,
4616            },
4617        );
4618        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4619        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4620        let entry = resolved
4621            .list_by_id("index.html")
4622            .unwrap_or_else(|| panic!("entry render plan should exist"));
4623        let page = resolved
4624            .list_by_id("dash-page")
4625            .unwrap_or_else(|| panic!("page render plan should exist"));
4626        // Only lists that actually contain a route pay for the sibling scan.
4627        assert!(entry.has_routes);
4628        assert!(!page.has_routes);
4629    }
4630
4631    #[test]
4632    fn prepared_prop_names_match_attribute_to_camel_exactly() {
4633        // Preparation writes the camelCase conversion straight into the shared
4634        // arena instead of calling `attribute_to_camel`, which allocates. The two
4635        // must stay byte-identical, including the irregular lookup-table entries.
4636        let names = [
4637            "data-title",
4638            ":data-title",
4639            "aria-labelledby",
4640            "aria-valuemax",
4641            "readonly",
4642            "tabindex",
4643            "class",
4644            "for",
4645            "maxlength",
4646            "novalidate",
4647            "already-camel",
4648            "trailing-",
4649            "-leading",
4650            "multi-part-attribute-name",
4651            "single",
4652            "",
4653        ];
4654        for name in names {
4655            let stripped = name.strip_prefix(':').unwrap_or(name);
4656            assert_eq!(
4657                component_attr_name(name),
4658                webui_protocol::attrs::attribute_to_camel(stripped),
4659                "prepared name diverged for `{name}`"
4660            );
4661        }
4662    }
4663
4664    #[test]
4665    fn prepared_component_prop_names_match_per_branch_stripping() {
4666        // The render path strips a leading `:` twice on the complex-value branch
4667        // and once elsewhere. Preparation must reproduce that per branch or a
4668        // `::`-prefixed binding would change the emitted component prop name.
4669        let complex = WebUIFragmentAttribute {
4670            name: "::data-title".to_string(),
4671            value: "source".to_string(),
4672            complex: true,
4673            ..Default::default()
4674        };
4675        assert_eq!(component_attr_source(&complex), ":data-title");
4676        assert_eq!(
4677            component_attr_name(component_attr_source(&complex)),
4678            "dataTitle"
4679        );
4680
4681        // `raw_value` takes the static branch, which strips only once.
4682        let raw = WebUIFragmentAttribute {
4683            raw_value: true,
4684            ..complex.clone()
4685        };
4686        assert_eq!(component_attr_source(&raw), "::data-title");
4687
4688        // A template attribute returns before the complex branch is reached.
4689        let templated = WebUIFragmentAttribute {
4690            template: "tpl".to_string(),
4691            ..complex.clone()
4692        };
4693        assert_eq!(component_attr_source(&templated), "::data-title");
4694
4695        // An empty value never reaches the complex branch either.
4696        let empty_value = WebUIFragmentAttribute {
4697            value: String::new(),
4698            ..complex.clone()
4699        };
4700        assert_eq!(component_attr_source(&empty_value), "::data-title");
4701
4702        // Ordinary single-colon bindings are unaffected on every branch.
4703        let ordinary = WebUIFragmentAttribute {
4704            name: ":data-title".to_string(),
4705            ..complex
4706        };
4707        assert_eq!(component_attr_source(&ordinary), "data-title");
4708        assert_eq!(
4709            component_attr_name(component_attr_source(&ordinary)),
4710            "dataTitle"
4711        );
4712    }
4713
4714    #[test]
4715    fn render_slots_match_continuation_slots() {
4716        // The streaming VM reads prepared metadata with the continuation slot
4717        // directly. That is only sound while both numberings index the same
4718        // sorted fragment IDs, so pin the invariant here rather than paying for
4719        // an ID lookup per streamed fragment frame.
4720        let mut fragments = HashMap::new();
4721        for id in [
4722            "index.html",
4723            "zeta-last",
4724            "alpha-first",
4725            "middle-one",
4726            "middle-two",
4727        ] {
4728            fragments.insert(
4729                id.to_string(),
4730                FragmentList {
4731                    fragments: vec![WebUIFragment::raw(id)],
4732                    contains_boundary: false,
4733                },
4734            );
4735        }
4736        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4737        let index = protocol.render_fragments();
4738        for slot in 0..u32::try_from(protocol.protocol().fragments.len())
4739            .unwrap_or_else(|_| panic!("fragment count fits in u32"))
4740        {
4741            let id = protocol
4742                .fragment_id(slot)
4743                .unwrap_or_else(|| panic!("slot {slot} should resolve to an ID"));
4744            assert_eq!(
4745                index.index(id),
4746                Some(slot as usize),
4747                "render slot must equal continuation slot for `{id}`"
4748            );
4749            assert_eq!(protocol.fragment_slot(id), Some(slot));
4750        }
4751    }
4752
4753    #[test]
4754    fn prepared_graph_reports_missing_fragments_without_panicking() {
4755        let mut fragments = HashMap::new();
4756        fragments.insert(
4757            "index.html".to_string(),
4758            FragmentList {
4759                fragments: vec![WebUIFragment::component("absent-child")],
4760                contains_boundary: false,
4761            },
4762        );
4763        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4764        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4765        let entry = resolved
4766            .list_by_id("index.html")
4767            .unwrap_or_else(|| panic!("entry render plan should exist"));
4768        // An unresolvable reference keeps a `None` target and must not index
4769        // out of range or silently render an unrelated fragment list.
4770        assert_eq!(entry.target(0), None);
4771        assert!(resolved.list_by_id("absent-child").is_none());
4772        assert!(resolved.list(usize::MAX).is_none());
4773
4774        let state = test_json!({});
4775        let options = RenderOptions::new("index.html", "/");
4776        let mut writer = TestWriter::new();
4777        match WebUIHandler::new().render(&protocol, &state, &options, &mut writer) {
4778            Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-child"),
4779            other => panic!("expected a missing fragment error, got {other:?}"),
4780        }
4781
4782        let mut writer = TestWriter::new();
4783        match WebUIHandler::new().render(
4784            &protocol,
4785            &state,
4786            &RenderOptions::new("absent-entry", "/"),
4787            &mut writer,
4788        ) {
4789            Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-entry"),
4790            other => panic!("expected a missing entry error, got {other:?}"),
4791        }
4792    }
4793
4794    #[test]
4795    fn prepared_graph_lazily_borrows_large_protocol_fragment_lists() {
4796        let list_count = INLINE_RENDER_FRAGMENT_LISTS * 2 + 1;
4797        let mut fragments = HashMap::with_capacity(list_count);
4798        for index in 0..list_count {
4799            fragments.insert(
4800                format!("fragment-{index}"),
4801                FragmentList {
4802                    fragments: vec![WebUIFragment::raw("content")],
4803                    contains_boundary: false,
4804                },
4805            );
4806        }
4807        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4808        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4809        // Nothing is materialized until a slot is actually rendered.
4810        assert!(resolved.cache[0].get().is_none());
4811        assert!(resolved.spilled.get().is_none());
4812        assert!(resolved.list(0).is_some());
4813        assert!(resolved.cache[0].get().is_some());
4814        // Rendering only inline slots never allocates the spill region.
4815        assert!(resolved.spilled.get().is_none());
4816        // Slots past the inline cache spill once, then stay memoized so a deep
4817        // descent never re-hashes the fragment ID.
4818        assert!(resolved.list(INLINE_RENDER_FRAGMENT_LISTS).is_some());
4819        let spilled = resolved
4820            .spilled
4821            .get()
4822            .expect("a slot past the inline cache should allocate the spill region");
4823        assert_eq!(spilled.len(), list_count - INLINE_RENDER_FRAGMENT_LISTS);
4824        assert!(spilled[0].get().is_some());
4825        assert!(resolved.list(list_count - 1).is_some());
4826
4827        for (id, source) in &protocol.protocol().fragments {
4828            let prepared = resolved
4829                .list_by_id(id)
4830                .unwrap_or_else(|| panic!("render plan should contain {id}"));
4831            // Borrowed, never duplicated: the prepared list points at protocol storage.
4832            assert!(std::ptr::eq(
4833                prepared.fragments.as_ptr(),
4834                source.fragments.as_ptr()
4835            ));
4836        }
4837    }
4838
4839    #[test]
4840    fn test_handle_raw() {
4841        // Create a simple protocol
4842        let mut fragments = HashMap::new();
4843        fragments.insert(
4844            "index.html".to_string(),
4845            FragmentList {
4846                fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
4847                contains_boundary: false,
4848            },
4849        );
4850
4851        let protocol = WebUIProtocol::new(fragments);
4852        let state = test_json!({});
4853
4854        // Create a test writer
4855        let mut writer = TestWriter::new();
4856
4857        // Handle the protocol
4858        assert!(
4859            handle(
4860                &protocol,
4861                &state,
4862                &RenderOptions::new("index.html", "/"),
4863                &mut writer
4864            )
4865            .is_ok(),
4866            "Failed to handle raw protocol"
4867        );
4868
4869        // Check the output
4870        assert_eq!(writer.get_content(), "Hello, WebUI!");
4871        assert!(writer.is_ended());
4872    }
4873
4874    #[test]
4875    fn test_handle_signal() {
4876        // Create a protocol with a signal
4877        let mut fragments = HashMap::new();
4878        fragments.insert(
4879            "index.html".to_string(),
4880            FragmentList {
4881                fragments: vec![
4882                    WebUIFragment::raw("Hello, "),
4883                    WebUIFragment::signal("name", false),
4884                    WebUIFragment::raw("!"),
4885                ],
4886                contains_boundary: false,
4887            },
4888        );
4889
4890        let protocol = WebUIProtocol::new(fragments);
4891        let state = test_json!({"name": "WebUI"});
4892
4893        // Create a test writer
4894        let mut writer = TestWriter::new();
4895
4896        // Handle the protocol
4897        assert!(
4898            handle(
4899                &protocol,
4900                &state,
4901                &RenderOptions::new("index.html", "/"),
4902                &mut writer
4903            )
4904            .is_ok(),
4905            "Failed to handle signal protocol"
4906        );
4907
4908        // Check the output
4909        assert_eq!(writer.get_content(), "Hello, WebUI!");
4910        assert!(writer.is_ended());
4911    }
4912
4913    #[test]
4914    fn test_handle_for_loop() {
4915        // Create a protocol with a for loop
4916        let mut fragments = HashMap::new();
4917        fragments.insert(
4918            "index.html".to_string(),
4919            FragmentList {
4920                fragments: vec![
4921                    WebUIFragment::raw("People: "),
4922                    WebUIFragment::for_loop("person", "people", "person-item"),
4923                ],
4924                contains_boundary: false,
4925            },
4926        );
4927
4928        fragments.insert(
4929            "person-item".to_string(),
4930            FragmentList {
4931                fragments: vec![
4932                    WebUIFragment::signal("person.name", false),
4933                    WebUIFragment::raw(", "),
4934                ],
4935                contains_boundary: false,
4936            },
4937        );
4938
4939        let protocol = WebUIProtocol::new(fragments);
4940        let state = test_json!({
4941            "people": [
4942                {"name": "Alice"},
4943                {"name": "Bob"},
4944                {"name": "Charlie"}
4945            ]
4946        });
4947
4948        // Create a test writer
4949        let mut writer = TestWriter::new();
4950
4951        // Handle the protocol
4952        assert!(
4953            handle(
4954                &protocol,
4955                &state,
4956                &RenderOptions::new("index.html", "/"),
4957                &mut writer
4958            )
4959            .is_ok(),
4960            "Failed to handle for loop protocol"
4961        );
4962
4963        // Check the output
4964        assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
4965        assert!(writer.is_ended());
4966    }
4967
4968    #[test]
4969    fn test_handle_if_condition() {
4970        // Create a protocol with an if condition
4971        let mut fragments = HashMap::new();
4972        fragments.insert(
4973            "index.html".to_string(),
4974            FragmentList {
4975                fragments: vec![
4976                    WebUIFragment::raw("Status: "),
4977                    WebUIFragment::if_cond(
4978                        webui_protocol::ConditionExpr::identifier("isActive"),
4979                        "active-content",
4980                    ),
4981                    WebUIFragment::raw("End"),
4982                ],
4983                contains_boundary: false,
4984            },
4985        );
4986
4987        fragments.insert(
4988            "active-content".to_string(),
4989            FragmentList {
4990                fragments: vec![WebUIFragment::raw("Active")],
4991                contains_boundary: false,
4992            },
4993        );
4994
4995        let protocol = WebUIProtocol::new(fragments);
4996
4997        // Test with isActive = true
4998        let state_true = test_json!({"isActive": true});
4999        let mut writer_true = TestWriter::new();
5000        assert!(
5001            handle(
5002                &protocol,
5003                &state_true,
5004                &RenderOptions::new("index.html", "/"),
5005                &mut writer_true
5006            )
5007            .is_ok(),
5008            "Failed to handle if condition (true case)"
5009        );
5010        assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
5011        assert!(writer_true.is_ended());
5012
5013        // Test with isActive = false
5014        let state_false = test_json!({"isActive": false});
5015        let mut writer_false = TestWriter::new();
5016        assert!(
5017            handle(
5018                &protocol,
5019                &state_false,
5020                &RenderOptions::new("index.html", "/"),
5021                &mut writer_false
5022            )
5023            .is_ok(),
5024            "Failed to handle if condition (false case)"
5025        );
5026        assert_eq!(writer_false.get_content(), "Status: End");
5027        assert!(writer_false.is_ended());
5028    }
5029
5030    #[test]
5031    fn missing_identifier_is_falsy_before_negation_in_global_and_loop_scopes() {
5032        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>"#;
5033        let mut parser = HtmlParser::new();
5034        parser
5035            .parse("index.html", source)
5036            .expect("parse missing-path condition fixture");
5037        let protocol = WebUIProtocol::new(parser.into_fragment_records());
5038        let state = test_json!({
5039            "items": [
5040                {"label": "Normal"},
5041                {"label": "Search", "searchPresentation": true}
5042            ]
5043        });
5044        let mut writer = TestWriter::new();
5045
5046        handle(
5047            &protocol,
5048            &state,
5049            &RenderOptions::new("index.html", "/"),
5050            &mut writer,
5051        )
5052        .expect("render missing-path condition fixture");
5053
5054        assert_eq!(
5055            writer.get_content(),
5056            "top-negated<span>Normal</span><mark>Search</mark>"
5057        );
5058    }
5059
5060    #[test]
5061    fn native_dynamic_attribute_does_not_leak_into_next_component() {
5062        let mut fragments = HashMap::new();
5063        fragments.insert(
5064            "index.html".to_string(),
5065            FragmentList {
5066                fragments: vec![
5067                    WebUIFragment::raw("<div"),
5068                    WebUIFragment {
5069                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5070                            WebUIFragmentAttribute {
5071                                name: "title".into(),
5072                                value: "nativeTitle".into(),
5073                                ..Default::default()
5074                            },
5075                        )),
5076                    },
5077                    WebUIFragment::raw("></div><my-comp>"),
5078                    WebUIFragment::component("my-comp"),
5079                    WebUIFragment::raw("</my-comp>"),
5080                ],
5081                contains_boundary: false,
5082            },
5083        );
5084        fragments.insert(
5085            "my-comp".to_string(),
5086            FragmentList {
5087                fragments: vec![WebUIFragment::signal("title", false)],
5088                contains_boundary: false,
5089            },
5090        );
5091        let protocol = WebUIProtocol::new(fragments);
5092        let state = test_json!({
5093            "nativeTitle": "native",
5094            "title": "global"
5095        });
5096        let mut writer = TestWriter::new();
5097        handle(
5098            &protocol,
5099            &state,
5100            &RenderOptions::new("index.html", "/"),
5101            &mut writer,
5102        )
5103        .unwrap_or_else(|error| panic!("render failed: {error}"));
5104        assert_eq!(
5105            writer.get_content(),
5106            "<div title=\"native\"></div><my-comp>global</my-comp>"
5107        );
5108    }
5109
5110    #[test]
5111    fn test_handle_component() {
5112        // Create a protocol with a component
5113        let mut fragments = HashMap::new();
5114        fragments.insert(
5115            "index.html".to_string(),
5116            FragmentList {
5117                fragments: vec![
5118                    WebUIFragment::raw("Component: "),
5119                    WebUIFragment::component("my-component"),
5120                ],
5121                contains_boundary: false,
5122            },
5123        );
5124
5125        fragments.insert(
5126            "my-component".to_string(),
5127            FragmentList {
5128                fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
5129                contains_boundary: false,
5130            },
5131        );
5132
5133        let protocol = WebUIProtocol::new(fragments);
5134        let state = test_json!({});
5135
5136        // Create a test writer
5137        let mut writer = TestWriter::new();
5138
5139        // Handle the protocol
5140        assert!(
5141            handle(
5142                &protocol,
5143                &state,
5144                &RenderOptions::new("index.html", "/"),
5145                &mut writer
5146            )
5147            .is_ok(),
5148            "Failed to handle component protocol"
5149        );
5150
5151        // Check the output
5152        assert_eq!(
5153            writer.get_content(),
5154            "Component: <div>Component Content</div>"
5155        );
5156        assert!(writer.is_ended());
5157    }
5158
5159    #[test]
5160    fn test_missing_fragment() {
5161        // Create a protocol with a missing fragment reference
5162        let mut fragments = HashMap::new();
5163        fragments.insert(
5164            "index.html".to_string(),
5165            FragmentList {
5166                fragments: vec![WebUIFragment::component("missing-component")],
5167                contains_boundary: false,
5168            },
5169        );
5170
5171        let protocol = WebUIProtocol::new(fragments);
5172        let state = test_json!({});
5173
5174        // Create a test writer
5175        let mut writer = TestWriter::new();
5176
5177        // Handle the protocol
5178        let result = handle(
5179            &protocol,
5180            &state,
5181            &RenderOptions::new("index.html", "/"),
5182            &mut writer,
5183        );
5184
5185        // Expect an error
5186        assert!(result.is_err());
5187        if let Err(HandlerError::MissingFragment(fragment_id)) = result {
5188            assert_eq!(fragment_id, "missing-component");
5189        } else {
5190            panic!("Expected MissingFragment error");
5191        }
5192    }
5193
5194    #[test]
5195    fn test_missing_signal_renders_empty() {
5196        // A signal referencing a field absent from state should render as empty
5197        let mut fragments = HashMap::new();
5198        fragments.insert(
5199            "index.html".to_string(),
5200            FragmentList {
5201                fragments: vec![
5202                    WebUIFragment::raw("Hello, "),
5203                    WebUIFragment::signal("missing_field", false),
5204                    WebUIFragment::raw("!"),
5205                ],
5206                contains_boundary: false,
5207            },
5208        );
5209
5210        let protocol = WebUIProtocol::new(fragments);
5211        let state = test_json!({});
5212
5213        let mut writer = TestWriter::new();
5214
5215        assert!(
5216            handle(
5217                &protocol,
5218                &state,
5219                &RenderOptions::new("index.html", "/"),
5220                &mut writer
5221            )
5222            .is_ok(),
5223            "Missing signal should not produce an error"
5224        );
5225
5226        assert_eq!(writer.get_content(), "Hello, !");
5227        assert!(writer.is_ended());
5228    }
5229
5230    // ── Boolean attribute rendering tests ─────────────────────────────
5231
5232    #[test]
5233    fn test_boolean_attr_true() {
5234        let mut fragments = HashMap::new();
5235        fragments.insert(
5236            "index.html".to_string(),
5237            FragmentList {
5238                fragments: vec![
5239                    WebUIFragment::raw("<button"),
5240                    WebUIFragment::attribute_boolean(
5241                        "disabled",
5242                        ConditionExpr::identifier("isDisabled"),
5243                    ),
5244                    WebUIFragment::raw(">Click</button>"),
5245                ],
5246                contains_boundary: false,
5247            },
5248        );
5249        let protocol = WebUIProtocol::new(fragments);
5250        let state = test_json!({"isDisabled": true});
5251        let mut writer = TestWriter::new();
5252        handle(
5253            &protocol,
5254            &state,
5255            &RenderOptions::new("index.html", "/"),
5256            &mut writer,
5257        )
5258        .unwrap();
5259        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
5260    }
5261
5262    #[test]
5263    fn test_boolean_attr_false() {
5264        let mut fragments = HashMap::new();
5265        fragments.insert(
5266            "index.html".to_string(),
5267            FragmentList {
5268                fragments: vec![
5269                    WebUIFragment::raw("<button"),
5270                    WebUIFragment::attribute_boolean(
5271                        "disabled",
5272                        ConditionExpr::identifier("isDisabled"),
5273                    ),
5274                    WebUIFragment::raw(">Click</button>"),
5275                ],
5276                contains_boundary: false,
5277            },
5278        );
5279        let protocol = WebUIProtocol::new(fragments);
5280        let state = test_json!({"isDisabled": false});
5281        let mut writer = TestWriter::new();
5282        handle(
5283            &protocol,
5284            &state,
5285            &RenderOptions::new("index.html", "/"),
5286            &mut writer,
5287        )
5288        .unwrap();
5289        assert_eq!(writer.get_content(), "<button>Click</button>");
5290    }
5291
5292    #[test]
5293    fn test_boolean_attr_missing() {
5294        let mut fragments = HashMap::new();
5295        fragments.insert(
5296            "index.html".to_string(),
5297            FragmentList {
5298                fragments: vec![
5299                    WebUIFragment::raw("<input type=\"checkbox\""),
5300                    WebUIFragment::attribute_boolean(
5301                        "checked",
5302                        ConditionExpr::identifier("checked"),
5303                    ),
5304                    WebUIFragment::raw(">"),
5305                ],
5306                contains_boundary: false,
5307            },
5308        );
5309        let protocol = WebUIProtocol::new(fragments);
5310        let state = test_json!({});
5311        let mut writer = TestWriter::new();
5312        handle(
5313            &protocol,
5314            &state,
5315            &RenderOptions::new("index.html", "/"),
5316            &mut writer,
5317        )
5318        .unwrap();
5319        assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
5320    }
5321
5322    #[test]
5323    fn test_boolean_attr_multiple() {
5324        let mut fragments = HashMap::new();
5325        fragments.insert(
5326            "index.html".to_string(),
5327            FragmentList {
5328                fragments: vec![
5329                    WebUIFragment::raw("<input type=\"checkbox\""),
5330                    WebUIFragment::attribute_boolean(
5331                        "checked",
5332                        ConditionExpr::identifier("checked"),
5333                    ),
5334                    WebUIFragment::attribute_boolean(
5335                        "disabled",
5336                        ConditionExpr::identifier("disabled"),
5337                    ),
5338                    WebUIFragment::raw(">"),
5339                ],
5340                contains_boundary: false,
5341            },
5342        );
5343        let protocol = WebUIProtocol::new(fragments);
5344        let state = test_json!({"checked": true, "disabled": false});
5345        let mut writer = TestWriter::new();
5346        handle(
5347            &protocol,
5348            &state,
5349            &RenderOptions::new("index.html", "/"),
5350            &mut writer,
5351        )
5352        .unwrap();
5353        assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
5354    }
5355
5356    // ── Simple attribute rendering tests ──────────────────────────────
5357
5358    #[test]
5359    fn test_attribute_with_value() {
5360        let mut fragments = HashMap::new();
5361        fragments.insert(
5362            "index.html".to_string(),
5363            FragmentList {
5364                fragments: vec![
5365                    WebUIFragment::raw("<input"),
5366                    WebUIFragment::attribute("value", "inputValue"),
5367                    WebUIFragment::raw(">"),
5368                ],
5369                contains_boundary: false,
5370            },
5371        );
5372        let protocol = WebUIProtocol::new(fragments);
5373        let state = test_json!({"inputValue": "Hello"});
5374        let mut writer = TestWriter::new();
5375        handle(
5376            &protocol,
5377            &state,
5378            &RenderOptions::new("index.html", "/"),
5379            &mut writer,
5380        )
5381        .unwrap();
5382        assert_eq!(writer.get_content(), "<input value=\"Hello\">");
5383    }
5384
5385    #[test]
5386    fn test_attribute_with_falsy_numeric() {
5387        let mut fragments = HashMap::new();
5388        fragments.insert(
5389            "index.html".to_string(),
5390            FragmentList {
5391                fragments: vec![
5392                    WebUIFragment::raw("<div name=\"test\""),
5393                    WebUIFragment::attribute("handle", "number"),
5394                    WebUIFragment::raw("></div>"),
5395                ],
5396                contains_boundary: false,
5397            },
5398        );
5399        let protocol = WebUIProtocol::new(fragments);
5400        let state = test_json!({"number": 0});
5401        let mut writer = TestWriter::new();
5402        handle(
5403            &protocol,
5404            &state,
5405            &RenderOptions::new("index.html", "/"),
5406            &mut writer,
5407        )
5408        .unwrap();
5409        assert_eq!(
5410            writer.get_content(),
5411            "<div name=\"test\" handle=\"0\"></div>"
5412        );
5413    }
5414
5415    // ── Dynamic attribute escaping for non-string JSON types ─────────
5416
5417    #[test]
5418    fn test_attribute_array_value_is_escaped() {
5419        let mut fragments = HashMap::new();
5420        fragments.insert(
5421            "index.html".to_string(),
5422            FragmentList {
5423                fragments: vec![
5424                    WebUIFragment::raw("<a"),
5425                    WebUIFragment::attribute("href", "value"),
5426                    WebUIFragment::raw(">demo</a>"),
5427                ],
5428                contains_boundary: false,
5429            },
5430        );
5431        let protocol = WebUIProtocol::new(fragments);
5432        let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
5433        let mut writer = TestWriter::new();
5434        handle(
5435            &protocol,
5436            &state,
5437            &RenderOptions::new("index.html", "/"),
5438            &mut writer,
5439        )
5440        .unwrap();
5441        let content = writer.get_content();
5442        // All inner double quotes must be entity-escaped so that the
5443        // browser never sees a second attribute boundary.
5444        assert!(
5445            content.contains("&quot;"),
5446            "Double quotes inside attribute value must be escaped: {content}"
5447        );
5448        // The href attribute value must be a single contiguous quoted
5449        // string — no extra attributes should appear.
5450        assert_eq!(
5451            content.matches("=\"").count(),
5452            1,
5453            "Only one attribute assignment expected: {content}"
5454        );
5455    }
5456
5457    #[test]
5458    fn test_attribute_object_value_is_escaped() {
5459        let mut fragments = HashMap::new();
5460        fragments.insert(
5461            "index.html".to_string(),
5462            FragmentList {
5463                fragments: vec![
5464                    WebUIFragment::raw("<div"),
5465                    WebUIFragment::attribute("data-cfg", "cfg"),
5466                    WebUIFragment::raw("></div>"),
5467                ],
5468                contains_boundary: false,
5469            },
5470        );
5471        let protocol = WebUIProtocol::new(fragments);
5472        let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
5473        let mut writer = TestWriter::new();
5474        handle(
5475            &protocol,
5476            &state,
5477            &RenderOptions::new("index.html", "/"),
5478            &mut writer,
5479        )
5480        .unwrap();
5481        let content = writer.get_content();
5482        assert!(
5483            content.contains("&quot;"),
5484            "Double quotes inside attribute value must be escaped: {content}"
5485        );
5486        assert_eq!(
5487            content.matches("=\"").count(),
5488            1,
5489            "Only one attribute assignment expected: {content}"
5490        );
5491    }
5492
5493    // ── Template attribute rendering tests ────────────────────────────
5494
5495    #[test]
5496    fn test_mixed_attribute_template() {
5497        let mut fragments = HashMap::new();
5498        fragments.insert(
5499            "index.html".to_string(),
5500            FragmentList {
5501                fragments: vec![
5502                    WebUIFragment::raw("<input"),
5503                    WebUIFragment::attribute_template("value", "attr-1"),
5504                    WebUIFragment::raw(">"),
5505                ],
5506                contains_boundary: false,
5507            },
5508        );
5509        fragments.insert(
5510            "attr-1".to_string(),
5511            FragmentList {
5512                fragments: vec![
5513                    WebUIFragment::raw("hello "),
5514                    WebUIFragment::signal("item", false),
5515                ],
5516                contains_boundary: false,
5517            },
5518        );
5519        let protocol = WebUIProtocol::new(fragments);
5520        let state = test_json!({"item": "world"});
5521        let mut writer = TestWriter::new();
5522        handle(
5523            &protocol,
5524            &state,
5525            &RenderOptions::new("index.html", "/"),
5526            &mut writer,
5527        )
5528        .unwrap();
5529        assert_eq!(writer.get_content(), "<input value=\"hello world\">");
5530    }
5531
5532    // ── Raw signal rendering test ─────────────────────────────────────
5533
5534    #[test]
5535    fn test_raw_signal_not_escaped() {
5536        let mut fragments = HashMap::new();
5537        fragments.insert(
5538            "index.html".to_string(),
5539            FragmentList {
5540                fragments: vec![
5541                    WebUIFragment::signal("html", false),
5542                    WebUIFragment::signal("html", true),
5543                ],
5544                contains_boundary: false,
5545            },
5546        );
5547        let protocol = WebUIProtocol::new(fragments);
5548        let state = test_json!({"html": "<strong>hi</strong>"});
5549        let mut writer = TestWriter::new();
5550        handle(
5551            &protocol,
5552            &state,
5553            &RenderOptions::new("index.html", "/"),
5554            &mut writer,
5555        )
5556        .unwrap();
5557        assert_eq!(
5558            writer.get_content(),
5559            "&lt;strong&gt;hi&lt;&#x2F;strong&gt;<strong>hi</strong>"
5560        );
5561    }
5562
5563    // ── Nested for loop tests ─────────────────────────────────────────
5564
5565    fn render_named_for(template: &str, state: &Value) -> String {
5566        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5567        parser.parse("index.html", template).unwrap();
5568        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
5569        let mut writer = TestWriter::new();
5570        WebUIHandler::new()
5571            .render(
5572                &protocol,
5573                state,
5574                &RenderOptions::new("index.html", "/"),
5575                &mut writer,
5576            )
5577            .unwrap();
5578        writer.get_content()
5579    }
5580
5581    #[test]
5582    fn named_for_renders_the_issue_tree_and_restores_shadowed_items() {
5583        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>"#;
5584        let state = test_json!({
5585            "child": { "name": "global" },
5586            "items": [
5587                { "name": "Colors", "children": [{ "name": "Ali" }, { "name": "Alice" }, { "name": "Bob" }] },
5588                { "name": "Name", "children": [] },
5589                { "name": "Hobbies", "children": [{ "name": "Sports", "children": [{ "name": "Futbol" }, { "name": "Cricket" }] }] }
5590            ]
5591        });
5592        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>");
5593    }
5594
5595    #[test]
5596    fn named_for_missing_and_empty_children_stop_recursion_without_an_if() {
5597        let source = r#"<for id="tree" each="item in items">{{item.name}}<for id="tree" each="item in item.children" />{{item.name}}</for>"#;
5598        let state = test_json!({
5599            "items": [{ "name": "a", "children": [{ "name": "b" }, { "name": "c", "children": [] }] }]
5600        });
5601        assert_eq!(render_named_for(source, &state), "abbcca");
5602        assert_eq!(render_named_for(source, &test_json!({"items": []})), "");
5603    }
5604
5605    #[test]
5606    fn named_for_forward_and_mutual_references_follow_finite_data() {
5607        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>"#;
5608        let state = test_json!({
5609            "items": [{ "name": "a", "children": [{ "name": "b", "children": [{ "name": "c" }] }] }],
5610            "empty": []
5611        });
5612        assert_eq!(render_named_for(source, &state), "<b>a</b><i>b</i><b>c</b>");
5613    }
5614
5615    #[test]
5616    fn named_for_preserves_enclosing_scope_and_escapes_nested_values() {
5617        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>"#;
5618        let state = test_json!({
5619            "groups": [
5620                { "name": "one", "items": [{ "name": "<root>", "children": [{ "name": "&leaf" }] }] },
5621                { "name": "two", "items": [{ "name": "last" }] }
5622            ]
5623        });
5624        assert_eq!(
5625            render_named_for(source, &state),
5626            "one:&lt;root&gt;;one:&amp;leaf;two:last;"
5627        );
5628    }
5629
5630    #[test]
5631    fn named_for_non_array_collection_returns_the_existing_type_error() {
5632        let mut parser = HtmlParser::new();
5633        parser.parse("index.html", r#"<for id="tree" each="item in items"><for id="tree" each="item in item.children" /></for>"#).unwrap();
5634        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
5635        let mut writer = TestWriter::new();
5636        let error = WebUIHandler::new()
5637            .render(
5638                &protocol,
5639                &test_json!({"items": [{"children": 1}]}),
5640                &RenderOptions::new("index.html", "/"),
5641                &mut writer,
5642            )
5643            .unwrap_err();
5644        assert!(matches!(error, HandlerError::TypeError(_)));
5645    }
5646
5647    #[test]
5648    fn test_nested_for_loop() {
5649        let mut fragments = HashMap::new();
5650        fragments.insert(
5651            "index.html".to_string(),
5652            FragmentList {
5653                fragments: vec![
5654                    WebUIFragment::raw("<div>"),
5655                    WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
5656                    WebUIFragment::raw("</div>"),
5657                ],
5658                contains_boundary: false,
5659            },
5660        );
5661        fragments.insert(
5662            "outer".to_string(),
5663            FragmentList {
5664                fragments: vec![
5665                    WebUIFragment::raw("<div>"),
5666                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
5667                    WebUIFragment::raw("</div>"),
5668                ],
5669                contains_boundary: false,
5670            },
5671        );
5672        fragments.insert(
5673            "inner".to_string(),
5674            FragmentList {
5675                fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
5676                contains_boundary: false,
5677            },
5678        );
5679        let protocol = WebUIProtocol::new(fragments);
5680        let state = test_json!({
5681            "outerItems": [
5682                {"innerItems": [{"name": "A"}, {"name": "B"}]},
5683                {"innerItems": [{"name": "C"}]}
5684            ]
5685        });
5686        let mut writer = TestWriter::new();
5687        handle(
5688            &protocol,
5689            &state,
5690            &RenderOptions::new("index.html", "/"),
5691            &mut writer,
5692        )
5693        .unwrap();
5694        assert_eq!(
5695            writer.get_content(),
5696            "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
5697        );
5698    }
5699
5700    #[test]
5701    fn test_nested_for_with_signals() {
5702        let mut fragments = HashMap::new();
5703        fragments.insert(
5704            "index.html".to_string(),
5705            FragmentList {
5706                fragments: vec![
5707                    WebUIFragment::raw("<div>"),
5708                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5709                    WebUIFragment::raw("</div>"),
5710                ],
5711                contains_boundary: false,
5712            },
5713        );
5714        fragments.insert(
5715            "outerTemplate".to_string(),
5716            FragmentList {
5717                fragments: vec![
5718                    WebUIFragment::raw("<div>"),
5719                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5720                    WebUIFragment::raw("</div>"),
5721                ],
5722                contains_boundary: false,
5723            },
5724        );
5725        fragments.insert(
5726            "innerTemplate".to_string(),
5727            FragmentList {
5728                fragments: vec![
5729                    WebUIFragment::raw("<span>"),
5730                    WebUIFragment::signal("innerItem.name", false),
5731                    WebUIFragment::raw("</span>"),
5732                ],
5733                contains_boundary: false,
5734            },
5735        );
5736        let protocol = WebUIProtocol::new(fragments);
5737        let state = test_json!({
5738            "outerItems": [
5739                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5740                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5741            ]
5742        });
5743        let mut writer = TestWriter::new();
5744        handle(
5745            &protocol,
5746            &state,
5747            &RenderOptions::new("index.html", "/"),
5748            &mut writer,
5749        )
5750        .unwrap();
5751        assert_eq!(
5752            writer.get_content(),
5753            "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
5754        );
5755    }
5756
5757    #[test]
5758    fn test_nested_for_with_global_state() {
5759        let mut fragments = HashMap::new();
5760        fragments.insert(
5761            "index.html".to_string(),
5762            FragmentList {
5763                fragments: vec![
5764                    WebUIFragment::raw("<div>"),
5765                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5766                    WebUIFragment::raw("</div>"),
5767                ],
5768                contains_boundary: false,
5769            },
5770        );
5771        fragments.insert(
5772            "outerTemplate".to_string(),
5773            FragmentList {
5774                fragments: vec![
5775                    WebUIFragment::raw("<div>"),
5776                    WebUIFragment::signal("globalOuter", false),
5777                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5778                    WebUIFragment::raw("</div>"),
5779                ],
5780                contains_boundary: false,
5781            },
5782        );
5783        fragments.insert(
5784            "innerTemplate".to_string(),
5785            FragmentList {
5786                fragments: vec![
5787                    WebUIFragment::raw("<span>"),
5788                    WebUIFragment::signal("innerItem.name", false),
5789                    WebUIFragment::signal("globalInner", false),
5790                    WebUIFragment::raw("</span>"),
5791                ],
5792                contains_boundary: false,
5793            },
5794        );
5795        let protocol = WebUIProtocol::new(fragments);
5796        let state = test_json!({
5797            "globalOuter": "GO",
5798            "globalInner": "GI",
5799            "outerItems": [
5800                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5801                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5802            ]
5803        });
5804        let mut writer = TestWriter::new();
5805        handle(
5806            &protocol,
5807            &state,
5808            &RenderOptions::new("index.html", "/"),
5809            &mut writer,
5810        )
5811        .unwrap();
5812        assert_eq!(
5813            writer.get_content(),
5814            "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
5815        );
5816    }
5817
5818    // ── For + If state scoping tests ──────────────────────────────────
5819
5820    #[test]
5821    fn test_if_in_for_uses_local_state() {
5822        let mut fragments = HashMap::new();
5823        fragments.insert(
5824            "index.html".to_string(),
5825            FragmentList {
5826                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5827                contains_boundary: false,
5828            },
5829        );
5830        fragments.insert(
5831            "item-tpl".to_string(),
5832            FragmentList {
5833                fragments: vec![WebUIFragment::if_cond(
5834                    ConditionExpr::identifier("item.visible"),
5835                    "visible-tpl",
5836                )],
5837                contains_boundary: false,
5838            },
5839        );
5840        fragments.insert(
5841            "visible-tpl".to_string(),
5842            FragmentList {
5843                fragments: vec![WebUIFragment::signal("item.name", false)],
5844                contains_boundary: false,
5845            },
5846        );
5847        let protocol = WebUIProtocol::new(fragments);
5848        let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
5849        let mut writer = TestWriter::new();
5850        handle(
5851            &protocol,
5852            &state,
5853            &RenderOptions::new("index.html", "/"),
5854            &mut writer,
5855        )
5856        .unwrap();
5857        assert_eq!(writer.get_content(), "Show");
5858    }
5859
5860    #[test]
5861    fn test_for_if_local_overrides_global() {
5862        let mut fragments = HashMap::new();
5863        fragments.insert(
5864            "index.html".to_string(),
5865            FragmentList {
5866                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5867                contains_boundary: false,
5868            },
5869        );
5870        fragments.insert(
5871            "item-tpl".to_string(),
5872            FragmentList {
5873                fragments: vec![WebUIFragment::if_cond(
5874                    ConditionExpr::identifier("item.flag"),
5875                    "show-tpl",
5876                )],
5877                contains_boundary: false,
5878            },
5879        );
5880        fragments.insert(
5881            "show-tpl".to_string(),
5882            FragmentList {
5883                fragments: vec![WebUIFragment::raw("yes")],
5884                contains_boundary: false,
5885            },
5886        );
5887        let protocol = WebUIProtocol::new(fragments);
5888        // Global flag is true, but local item.flag is false for second item
5889        let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
5890        let mut writer = TestWriter::new();
5891        handle(
5892            &protocol,
5893            &state,
5894            &RenderOptions::new("index.html", "/"),
5895            &mut writer,
5896        )
5897        .unwrap();
5898        assert_eq!(writer.get_content(), "yes");
5899    }
5900
5901    // ── Component attribute state tests ───────────────────────────────
5902
5903    #[test]
5904    fn html_component_inputs_forward_flags_and_aria_without_javascript_metadata() {
5905        let cases = [
5906            (
5907                r#"<my-drawer open aria-label="Canvas information"></my-drawer>"#,
5908                test_json!({}),
5909                r#"<dialog open aria-label="Canvas information"></dialog>"#,
5910            ),
5911            (
5912                r#"<my-drawer ?open="{{open}}" aria-label="Canvas information"></my-drawer>"#,
5913                test_json!({"open": true}),
5914                r#"<dialog open aria-label="Canvas information"></dialog>"#,
5915            ),
5916            (
5917                r#"<my-drawer ?open="{{open}}" aria-label="Canvas information"></my-drawer>"#,
5918                test_json!({"open": false}),
5919                r#"<dialog aria-label="Canvas information"></dialog>"#,
5920            ),
5921            (
5922                r#"<my-drawer aria-label=""></my-drawer>"#,
5923                test_json!({"ariaLabel": "Fallback"}),
5924                r#"<dialog aria-label=""></dialog>"#,
5925            ),
5926            (
5927                r#"<my-drawer open aria-label="Canvas &amp; information"></my-drawer>"#,
5928                test_json!({}),
5929                r#"<dialog open aria-label="Canvas &amp; information"></dialog>"#,
5930            ),
5931        ];
5932        for (input, state, expected) in cases {
5933            let mut parser = HtmlParser::with_options(DomStrategy::Light);
5934            parser
5935                .component_registry_mut()
5936                .register_component(ComponentRegistration::new(
5937                    "my-drawer",
5938                    r#"<dialog ?open="{{open}}" aria-label="{{ariaLabel}}"></dialog>"#,
5939                    None,
5940                    true,
5941                ))
5942                .unwrap();
5943            parser.parse("index.html", input).unwrap();
5944            let protocol = WebUIProtocol::new(parser.into_fragment_records());
5945            let mut writer = TestWriter::new();
5946            handle(
5947                &protocol,
5948                &state,
5949                &RenderOptions::new("index.html", "/"),
5950                &mut writer,
5951            )
5952            .unwrap();
5953            assert!(
5954                writer.get_content().contains(expected),
5955                "{input}: {}",
5956                writer.get_content()
5957            );
5958        }
5959    }
5960
5961    #[test]
5962    fn test_component_attr_state_simple() {
5963        let mut fragments = HashMap::new();
5964        fragments.insert(
5965            "index.html".to_string(),
5966            FragmentList {
5967                fragments: vec![
5968                    WebUIFragment::raw("<my-comp"),
5969                    WebUIFragment {
5970                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5971                            WebUIFragmentAttribute {
5972                                name: "title".into(),
5973                                value: "Attribute Title".into(),
5974                                attr_start: true,
5975                                raw_value: true,
5976                                ..Default::default()
5977                            },
5978                        )),
5979                    },
5980                    WebUIFragment::raw(">"),
5981                    WebUIFragment::component("my-comp"),
5982                    WebUIFragment::raw("</my-comp>"),
5983                ],
5984
5985                contains_boundary: false,
5986            },
5987        );
5988        fragments.insert(
5989            "my-comp".to_string(),
5990            FragmentList {
5991                fragments: vec![
5992                    WebUIFragment::raw("<span>"),
5993                    WebUIFragment::signal("title", false),
5994                    WebUIFragment::raw("</span>"),
5995                ],
5996                contains_boundary: false,
5997            },
5998        );
5999        let protocol = WebUIProtocol::new(fragments);
6000        let state = test_json!({"title": "Global Title"});
6001        let mut writer = TestWriter::new();
6002        handle(
6003            &protocol,
6004            &state,
6005            &RenderOptions::new("index.html", "/"),
6006            &mut writer,
6007        )
6008        .unwrap();
6009        assert_eq!(
6010            writer.get_content(),
6011            "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
6012        );
6013    }
6014
6015    #[test]
6016    fn test_component_attr_state_template() {
6017        let mut fragments = HashMap::new();
6018        fragments.insert(
6019            "index.html".to_string(),
6020            FragmentList {
6021                fragments: vec![
6022                    WebUIFragment::raw("<my-comp"),
6023                    WebUIFragment {
6024                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6025                            WebUIFragmentAttribute {
6026                                name: "title".into(),
6027                                template: "title-attr".into(),
6028                                attr_start: true,
6029                                ..Default::default()
6030                            },
6031                        )),
6032                    },
6033                    WebUIFragment::raw(">"),
6034                    WebUIFragment::component("my-comp"),
6035                    WebUIFragment::raw("</my-comp>"),
6036                ],
6037
6038                contains_boundary: false,
6039            },
6040        );
6041        fragments.insert(
6042            "title-attr".to_string(),
6043            FragmentList {
6044                fragments: vec![
6045                    WebUIFragment::raw("hello "),
6046                    WebUIFragment::signal("item", false),
6047                ],
6048                contains_boundary: false,
6049            },
6050        );
6051        fragments.insert(
6052            "my-comp".to_string(),
6053            FragmentList {
6054                fragments: vec![
6055                    WebUIFragment::raw("<span>"),
6056                    WebUIFragment::signal("title", false),
6057                    WebUIFragment::raw("</span>"),
6058                ],
6059                contains_boundary: false,
6060            },
6061        );
6062        let protocol = WebUIProtocol::new(fragments);
6063        let state = test_json!({"item": "<world>"});
6064        let mut writer = TestWriter::new();
6065        handle(
6066            &protocol,
6067            &state,
6068            &RenderOptions::new("index.html", "/"),
6069            &mut writer,
6070        )
6071        .unwrap();
6072        assert_eq!(
6073            writer.get_content(),
6074            "<my-comp title=\"hello &lt;world&gt;\"><span>hello &lt;world&gt;</span></my-comp>"
6075        );
6076    }
6077
6078    #[test]
6079    fn test_component_attr_camel_case() {
6080        let mut fragments = HashMap::new();
6081        fragments.insert(
6082            "index.html".to_string(),
6083            FragmentList {
6084                fragments: vec![
6085                    WebUIFragment::raw("<my-comp"),
6086                    WebUIFragment {
6087                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6088                            WebUIFragmentAttribute {
6089                                name: "data-title".into(),
6090                                template: "dt-attr".into(),
6091                                attr_start: true,
6092                                ..Default::default()
6093                            },
6094                        )),
6095                    },
6096                    WebUIFragment::raw(">"),
6097                    WebUIFragment::component("my-comp"),
6098                    WebUIFragment::raw("</my-comp>"),
6099                ],
6100
6101                contains_boundary: false,
6102            },
6103        );
6104        fragments.insert(
6105            "dt-attr".to_string(),
6106            FragmentList {
6107                fragments: vec![
6108                    WebUIFragment::raw("prefix "),
6109                    WebUIFragment::signal("item", false),
6110                ],
6111                contains_boundary: false,
6112            },
6113        );
6114        fragments.insert(
6115            "my-comp".to_string(),
6116            FragmentList {
6117                fragments: vec![
6118                    WebUIFragment::raw("<span>"),
6119                    WebUIFragment::signal("dataTitle", false),
6120                    WebUIFragment::raw("</span>"),
6121                ],
6122                contains_boundary: false,
6123            },
6124        );
6125        let protocol = WebUIProtocol::new(fragments);
6126        let state = test_json!({"item": "a&b"});
6127        let mut writer = TestWriter::new();
6128        handle(
6129            &protocol,
6130            &state,
6131            &RenderOptions::new("index.html", "/"),
6132            &mut writer,
6133        )
6134        .unwrap();
6135        assert_eq!(
6136            writer.get_content(),
6137            "<my-comp data-title=\"prefix a&amp;b\"><span>prefix a&amp;b</span></my-comp>"
6138        );
6139    }
6140
6141    #[test]
6142    fn test_component_complex_attr() {
6143        let mut fragments = HashMap::new();
6144        fragments.insert(
6145            "index.html".to_string(),
6146            FragmentList {
6147                fragments: vec![
6148                    WebUIFragment::raw("<my-comp"),
6149                    WebUIFragment {
6150                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6151                            WebUIFragmentAttribute {
6152                                name: ":item".into(),
6153                                value: "complexItem".into(),
6154                                attr_start: true,
6155                                complex: true,
6156                                ..Default::default()
6157                            },
6158                        )),
6159                    },
6160                    WebUIFragment::raw(">"),
6161                    WebUIFragment::component("my-comp"),
6162                    WebUIFragment::raw("</my-comp>"),
6163                ],
6164
6165                contains_boundary: false,
6166            },
6167        );
6168        fragments.insert(
6169            "my-comp".to_string(),
6170            FragmentList {
6171                fragments: vec![
6172                    WebUIFragment::raw("<span>"),
6173                    WebUIFragment::signal("item.foo", false),
6174                    WebUIFragment::raw("</span><p>"),
6175                    WebUIFragment::signal("item.bar", false),
6176                    WebUIFragment::raw("</p>"),
6177                ],
6178                contains_boundary: false,
6179            },
6180        );
6181        let protocol = WebUIProtocol::new(fragments);
6182        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
6183        let mut writer = TestWriter::new();
6184        handle(
6185            &protocol,
6186            &state,
6187            &RenderOptions::new("index.html", "/"),
6188            &mut writer,
6189        )
6190        .unwrap();
6191        assert_eq!(
6192            writer.get_content(),
6193            "<my-comp><span>1</span><p>true</p></my-comp>"
6194        );
6195    }
6196
6197    #[test]
6198    fn test_component_no_parent_pollution() {
6199        let mut fragments = HashMap::new();
6200        fragments.insert(
6201            "index.html".to_string(),
6202            FragmentList {
6203                fragments: vec![
6204                    WebUIFragment::raw("<parent"),
6205                    WebUIFragment {
6206                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6207                            WebUIFragmentAttribute {
6208                                name: "var".into(),
6209                                value: "var".into(),
6210                                attr_start: true,
6211                                ..Default::default()
6212                            },
6213                        )),
6214                    },
6215                    WebUIFragment::raw(">"),
6216                    WebUIFragment::component("parent"),
6217                    WebUIFragment::raw("</parent>"),
6218                ],
6219
6220                contains_boundary: false,
6221            },
6222        );
6223        fragments.insert(
6224            "parent".to_string(),
6225            FragmentList {
6226                fragments: vec![
6227                    WebUIFragment::raw("Before: "),
6228                    WebUIFragment::signal("var", false),
6229                    WebUIFragment::raw("<child foo"),
6230                    WebUIFragment {
6231                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6232                            WebUIFragmentAttribute {
6233                                name: "var".into(),
6234                                value: "replaced".into(),
6235                                raw_value: true,
6236                                ..Default::default()
6237                            },
6238                        )),
6239                    },
6240                    WebUIFragment::raw(">"),
6241                    WebUIFragment::component("child"),
6242                    WebUIFragment::raw("Label</child>After: "),
6243                    WebUIFragment::signal("var", false),
6244                ],
6245
6246                contains_boundary: false,
6247            },
6248        );
6249        fragments.insert(
6250            "child".to_string(),
6251            FragmentList {
6252                fragments: vec![],
6253                contains_boundary: false,
6254            },
6255        );
6256        let protocol = WebUIProtocol::new(fragments);
6257        let state = test_json!({"var": "original<&"});
6258        let mut writer = TestWriter::new();
6259        handle(
6260            &protocol,
6261            &state,
6262            &RenderOptions::new("index.html", "/"),
6263            &mut writer,
6264        )
6265        .unwrap();
6266        assert_eq!(
6267            writer.get_content(),
6268            "<parent var=\"original&lt;&amp;\">Before: original&lt;&amp;<child foo var=\"replaced\">Label</child>After: original&lt;&amp;</parent>"
6269        );
6270    }
6271
6272    #[test]
6273    fn test_component_boolean_attr_state() {
6274        let mut fragments = HashMap::new();
6275        fragments.insert(
6276            "index.html".to_string(),
6277            FragmentList {
6278                fragments: vec![
6279                    WebUIFragment::raw("<my-comp"),
6280                    WebUIFragment {
6281                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6282                            WebUIFragmentAttribute {
6283                                name: "disabled".into(),
6284                                attr_start: true,
6285                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
6286                                ..Default::default()
6287                            },
6288                        )),
6289                    },
6290                    WebUIFragment::raw(">"),
6291                    WebUIFragment::component("my-comp"),
6292                    WebUIFragment::raw("</my-comp>"),
6293                ],
6294
6295                contains_boundary: false,
6296            },
6297        );
6298        fragments.insert(
6299            "my-comp".to_string(),
6300            FragmentList {
6301                fragments: vec![WebUIFragment::if_cond(
6302                    ConditionExpr::identifier("disabled"),
6303                    "show",
6304                )],
6305                contains_boundary: false,
6306            },
6307        );
6308        fragments.insert(
6309            "show".to_string(),
6310            FragmentList {
6311                fragments: vec![WebUIFragment::raw("disabled!")],
6312                contains_boundary: false,
6313            },
6314        );
6315        let protocol = WebUIProtocol::new(fragments);
6316        let state = test_json!({"isDisabled": true});
6317        let mut writer = TestWriter::new();
6318        handle(
6319            &protocol,
6320            &state,
6321            &RenderOptions::new("index.html", "/"),
6322            &mut writer,
6323        )
6324        .unwrap();
6325        assert_eq!(
6326            writer.get_content(),
6327            "<my-comp disabled>disabled!</my-comp>"
6328        );
6329    }
6330
6331    // ===== HTML Escape Tests (ported from utils.test.js escapeHtml) =====
6332
6333    /// Helper: render a signal value through the handler and return the escaped output.
6334    fn render_signal(value: &str) -> String {
6335        let mut fragments = HashMap::new();
6336        fragments.insert(
6337            "index.html".to_string(),
6338            FragmentList {
6339                fragments: vec![WebUIFragment::signal("v", false)],
6340                contains_boundary: false,
6341            },
6342        );
6343        let protocol = WebUIProtocol::new(fragments);
6344        let state = test_json!({"v": value});
6345        let mut writer = TestWriter::new();
6346        handle(
6347            &protocol,
6348            &state,
6349            &RenderOptions::new("index.html", "/"),
6350            &mut writer,
6351        )
6352        .unwrap();
6353        writer.get_content()
6354    }
6355
6356    #[test]
6357    fn test_escape_ampersand() {
6358        assert_eq!(render_signal("&"), "&amp;");
6359    }
6360
6361    #[test]
6362    fn test_escape_less_than() {
6363        assert_eq!(render_signal("<"), "&lt;");
6364    }
6365
6366    #[test]
6367    fn test_escape_greater_than() {
6368        assert_eq!(render_signal(">"), "&gt;");
6369    }
6370
6371    #[test]
6372    fn test_escape_double_quote() {
6373        assert_eq!(render_signal("\""), "&quot;");
6374    }
6375
6376    #[test]
6377    fn test_escape_single_quote() {
6378        // encode_safe escapes ' as &#x27;
6379        let result = render_signal("'");
6380        assert_eq!(
6381            result, "&#x27;",
6382            "Expected &#x27; for single quote, got: {result}"
6383        );
6384    }
6385
6386    #[test]
6387    fn test_escape_multiple_special_chars() {
6388        let result = render_signal("<script>alert('xss');</script>");
6389        assert!(
6390            result.contains("&lt;") && result.contains("&gt;"),
6391            "Expected escaped HTML, got: {}",
6392            result
6393        );
6394        assert!(
6395            !result.contains("<script>"),
6396            "Should not contain raw <script> tag"
6397        );
6398    }
6399
6400    #[test]
6401    fn test_escape_no_special_chars() {
6402        assert_eq!(render_signal("Hello World"), "Hello World");
6403    }
6404
6405    #[test]
6406    fn test_escape_empty_string() {
6407        assert_eq!(render_signal(""), "");
6408    }
6409
6410    #[test]
6411    fn test_escape_special_at_beginning() {
6412        let result = render_signal("<Hello");
6413        assert!(
6414            result.starts_with("&lt;"),
6415            "Expected &lt; at start, got: {}",
6416            result
6417        );
6418    }
6419
6420    #[test]
6421    fn test_escape_special_at_end() {
6422        let result = render_signal("Hello>");
6423        assert!(
6424            result.ends_with("&gt;"),
6425            "Expected &gt; at end, got: {}",
6426            result
6427        );
6428    }
6429
6430    #[test]
6431    fn test_escape_special_in_middle() {
6432        let result = render_signal("Hel&lo");
6433        assert!(
6434            result.contains("&amp;"),
6435            "Expected &amp; in middle, got: {}",
6436            result
6437        );
6438    }
6439
6440    // ── GROUP 5: Boolean Attribute Edge Cases ─────────────────────────
6441
6442    #[test]
6443    fn test_boolean_attr_truthy_values() {
6444        // checked: 1
6445        {
6446            let mut fragments = HashMap::new();
6447            fragments.insert(
6448                "index.html".to_string(),
6449                FragmentList {
6450                    fragments: vec![
6451                        WebUIFragment::raw("<input"),
6452                        WebUIFragment::attribute_boolean(
6453                            "checked",
6454                            ConditionExpr::identifier("checked"),
6455                        ),
6456                        WebUIFragment::raw(">"),
6457                    ],
6458                    contains_boundary: false,
6459                },
6460            );
6461            let protocol = WebUIProtocol::new(fragments);
6462            let state = test_json!({"checked": 1});
6463            let mut writer = TestWriter::new();
6464            handle(
6465                &protocol,
6466                &state,
6467                &RenderOptions::new("index.html", "/"),
6468                &mut writer,
6469            )
6470            .unwrap();
6471            assert_eq!(writer.get_content(), "<input checked>");
6472        }
6473        // checked: "yes"
6474        {
6475            let mut fragments = HashMap::new();
6476            fragments.insert(
6477                "index.html".to_string(),
6478                FragmentList {
6479                    fragments: vec![
6480                        WebUIFragment::raw("<input"),
6481                        WebUIFragment::attribute_boolean(
6482                            "checked",
6483                            ConditionExpr::identifier("checked"),
6484                        ),
6485                        WebUIFragment::raw(">"),
6486                    ],
6487                    contains_boundary: false,
6488                },
6489            );
6490            let protocol = WebUIProtocol::new(fragments);
6491            let state = test_json!({"checked": "yes"});
6492            let mut writer = TestWriter::new();
6493            handle(
6494                &protocol,
6495                &state,
6496                &RenderOptions::new("index.html", "/"),
6497                &mut writer,
6498            )
6499            .unwrap();
6500            assert_eq!(writer.get_content(), "<input checked>");
6501        }
6502        // checked: {} (empty object is truthy)
6503        {
6504            let mut fragments = HashMap::new();
6505            fragments.insert(
6506                "index.html".to_string(),
6507                FragmentList {
6508                    fragments: vec![
6509                        WebUIFragment::raw("<input"),
6510                        WebUIFragment::attribute_boolean(
6511                            "checked",
6512                            ConditionExpr::identifier("checked"),
6513                        ),
6514                        WebUIFragment::raw(">"),
6515                    ],
6516                    contains_boundary: false,
6517                },
6518            );
6519            let protocol = WebUIProtocol::new(fragments);
6520            let state = test_json!({"checked": {}});
6521            let mut writer = TestWriter::new();
6522            handle(
6523                &protocol,
6524                &state,
6525                &RenderOptions::new("index.html", "/"),
6526                &mut writer,
6527            )
6528            .unwrap();
6529            // Empty object is falsy in this expression evaluator
6530            assert_eq!(writer.get_content(), "<input>");
6531        }
6532        // checked: "false" (string "false" is truthy)
6533        {
6534            let mut fragments = HashMap::new();
6535            fragments.insert(
6536                "index.html".to_string(),
6537                FragmentList {
6538                    fragments: vec![
6539                        WebUIFragment::raw("<input"),
6540                        WebUIFragment::attribute_boolean(
6541                            "checked",
6542                            ConditionExpr::identifier("checked"),
6543                        ),
6544                        WebUIFragment::raw(">"),
6545                    ],
6546                    contains_boundary: false,
6547                },
6548            );
6549            let protocol = WebUIProtocol::new(fragments);
6550            let state = test_json!({"checked": "false"});
6551            let mut writer = TestWriter::new();
6552            handle(
6553                &protocol,
6554                &state,
6555                &RenderOptions::new("index.html", "/"),
6556                &mut writer,
6557            )
6558            .unwrap();
6559            assert_eq!(writer.get_content(), "<input checked>");
6560        }
6561    }
6562
6563    #[test]
6564    fn test_boolean_attr_falsy_values() {
6565        // checked: 0
6566        {
6567            let mut fragments = HashMap::new();
6568            fragments.insert(
6569                "index.html".to_string(),
6570                FragmentList {
6571                    fragments: vec![
6572                        WebUIFragment::raw("<input"),
6573                        WebUIFragment::attribute_boolean(
6574                            "checked",
6575                            ConditionExpr::identifier("checked"),
6576                        ),
6577                        WebUIFragment::raw(">"),
6578                    ],
6579                    contains_boundary: false,
6580                },
6581            );
6582            let protocol = WebUIProtocol::new(fragments);
6583            let state = test_json!({"checked": 0});
6584            let mut writer = TestWriter::new();
6585            handle(
6586                &protocol,
6587                &state,
6588                &RenderOptions::new("index.html", "/"),
6589                &mut writer,
6590            )
6591            .unwrap();
6592            assert_eq!(writer.get_content(), "<input>");
6593        }
6594        // checked: ""
6595        {
6596            let mut fragments = HashMap::new();
6597            fragments.insert(
6598                "index.html".to_string(),
6599                FragmentList {
6600                    fragments: vec![
6601                        WebUIFragment::raw("<input"),
6602                        WebUIFragment::attribute_boolean(
6603                            "checked",
6604                            ConditionExpr::identifier("checked"),
6605                        ),
6606                        WebUIFragment::raw(">"),
6607                    ],
6608                    contains_boundary: false,
6609                },
6610            );
6611            let protocol = WebUIProtocol::new(fragments);
6612            let state = test_json!({"checked": ""});
6613            let mut writer = TestWriter::new();
6614            handle(
6615                &protocol,
6616                &state,
6617                &RenderOptions::new("index.html", "/"),
6618                &mut writer,
6619            )
6620            .unwrap();
6621            assert_eq!(writer.get_content(), "<input>");
6622        }
6623        // checked: false
6624        {
6625            let mut fragments = HashMap::new();
6626            fragments.insert(
6627                "index.html".to_string(),
6628                FragmentList {
6629                    fragments: vec![
6630                        WebUIFragment::raw("<input"),
6631                        WebUIFragment::attribute_boolean(
6632                            "checked",
6633                            ConditionExpr::identifier("checked"),
6634                        ),
6635                        WebUIFragment::raw(">"),
6636                    ],
6637                    contains_boundary: false,
6638                },
6639            );
6640            let protocol = WebUIProtocol::new(fragments);
6641            let state = test_json!({"checked": false});
6642            let mut writer = TestWriter::new();
6643            handle(
6644                &protocol,
6645                &state,
6646                &RenderOptions::new("index.html", "/"),
6647                &mut writer,
6648            )
6649            .unwrap();
6650            assert_eq!(writer.get_content(), "<input>");
6651        }
6652        // no checked key at all
6653        {
6654            let mut fragments = HashMap::new();
6655            fragments.insert(
6656                "index.html".to_string(),
6657                FragmentList {
6658                    fragments: vec![
6659                        WebUIFragment::raw("<input"),
6660                        WebUIFragment::attribute_boolean(
6661                            "checked",
6662                            ConditionExpr::identifier("checked"),
6663                        ),
6664                        WebUIFragment::raw(">"),
6665                    ],
6666                    contains_boundary: false,
6667                },
6668            );
6669            let protocol = WebUIProtocol::new(fragments);
6670            let state = test_json!({});
6671            let mut writer = TestWriter::new();
6672            handle(
6673                &protocol,
6674                &state,
6675                &RenderOptions::new("index.html", "/"),
6676                &mut writer,
6677            )
6678            .unwrap();
6679            assert_eq!(writer.get_content(), "<input>");
6680        }
6681    }
6682
6683    #[test]
6684    fn test_boolean_attr_expression_true() {
6685        let mut fragments = HashMap::new();
6686        fragments.insert(
6687            "index.html".to_string(),
6688            FragmentList {
6689                fragments: vec![
6690                    WebUIFragment::raw("<button"),
6691                    WebUIFragment::attribute_boolean(
6692                        "disabled",
6693                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6694                    ),
6695                    WebUIFragment::raw(">Click</button>"),
6696                ],
6697                contains_boundary: false,
6698            },
6699        );
6700        let protocol = WebUIProtocol::new(fragments);
6701        let state = test_json!({"itemCount": 5});
6702        let mut writer = TestWriter::new();
6703        handle(
6704            &protocol,
6705            &state,
6706            &RenderOptions::new("index.html", "/"),
6707            &mut writer,
6708        )
6709        .unwrap();
6710        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
6711    }
6712
6713    #[test]
6714    fn test_boolean_attr_expression_false() {
6715        let mut fragments = HashMap::new();
6716        fragments.insert(
6717            "index.html".to_string(),
6718            FragmentList {
6719                fragments: vec![
6720                    WebUIFragment::raw("<button"),
6721                    WebUIFragment::attribute_boolean(
6722                        "disabled",
6723                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6724                    ),
6725                    WebUIFragment::raw(">Click</button>"),
6726                ],
6727                contains_boundary: false,
6728            },
6729        );
6730        let protocol = WebUIProtocol::new(fragments);
6731        let state = test_json!({"itemCount": 3});
6732        let mut writer = TestWriter::new();
6733        handle(
6734            &protocol,
6735            &state,
6736            &RenderOptions::new("index.html", "/"),
6737            &mut writer,
6738        )
6739        .unwrap();
6740        assert_eq!(writer.get_content(), "<button>Click</button>");
6741    }
6742
6743    // ── GROUP 6: Mixed Attributes ─────────────────────────────────────
6744
6745    #[test]
6746    fn test_nested_component_attr_capture() {
6747        let mut fragments = HashMap::new();
6748        fragments.insert(
6749            "index.html".to_string(),
6750            FragmentList {
6751                fragments: vec![
6752                    WebUIFragment::raw("<parent-component"),
6753                    WebUIFragment {
6754                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6755                            WebUIFragmentAttribute {
6756                                name: "title".into(),
6757                                template: "parent-title".into(),
6758                                attr_start: true,
6759                                ..Default::default()
6760                            },
6761                        )),
6762                    },
6763                    WebUIFragment::raw(">"),
6764                    WebUIFragment::component("parent-component"),
6765                    WebUIFragment::raw("</parent-component>"),
6766                ],
6767
6768                contains_boundary: false,
6769            },
6770        );
6771        fragments.insert(
6772            "parent-title".to_string(),
6773            FragmentList {
6774                fragments: vec![
6775                    WebUIFragment::raw("Hello "),
6776                    WebUIFragment::signal("who", false),
6777                ],
6778                contains_boundary: false,
6779            },
6780        );
6781        fragments.insert(
6782            "parent-component".to_string(),
6783            FragmentList {
6784                fragments: vec![
6785                    WebUIFragment::raw("<child-component"),
6786                    WebUIFragment {
6787                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6788                            WebUIFragmentAttribute {
6789                                name: "title".into(),
6790                                template: "child-title".into(),
6791                                attr_start: true,
6792                                ..Default::default()
6793                            },
6794                        )),
6795                    },
6796                    WebUIFragment::raw(">"),
6797                    WebUIFragment::component("child-component"),
6798                    WebUIFragment::raw("</child-component>"),
6799                ],
6800
6801                contains_boundary: false,
6802            },
6803        );
6804        fragments.insert(
6805            "child-title".to_string(),
6806            FragmentList {
6807                fragments: vec![
6808                    WebUIFragment::raw("Child of "),
6809                    WebUIFragment::signal("title", false),
6810                ],
6811                contains_boundary: false,
6812            },
6813        );
6814        fragments.insert(
6815            "child-component".to_string(),
6816            FragmentList {
6817                fragments: vec![
6818                    WebUIFragment::raw("<span>"),
6819                    WebUIFragment::signal("title", false),
6820                    WebUIFragment::raw("</span>"),
6821                ],
6822                contains_boundary: false,
6823            },
6824        );
6825        let protocol = WebUIProtocol::new(fragments);
6826        let state = test_json!({"who": "<world>"});
6827        let mut writer = TestWriter::new();
6828        handle(
6829            &protocol,
6830            &state,
6831            &RenderOptions::new("index.html", "/"),
6832            &mut writer,
6833        )
6834        .unwrap();
6835        assert_eq!(
6836            writer.get_content(),
6837            "<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>"
6838        );
6839    }
6840
6841    #[test]
6842    fn test_grandchild_attr_propagation() {
6843        let mut fragments = HashMap::new();
6844        fragments.insert(
6845            "index.html".to_string(),
6846            FragmentList {
6847                fragments: vec![
6848                    WebUIFragment::raw("<parent-component"),
6849                    WebUIFragment {
6850                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6851                            WebUIFragmentAttribute {
6852                                name: "title".into(),
6853                                template: "p-title".into(),
6854                                attr_start: true,
6855                                ..Default::default()
6856                            },
6857                        )),
6858                    },
6859                    WebUIFragment::raw(">"),
6860                    WebUIFragment::component("parent-component"),
6861                    WebUIFragment::raw("</parent-component>"),
6862                ],
6863
6864                contains_boundary: false,
6865            },
6866        );
6867        fragments.insert(
6868            "p-title".to_string(),
6869            FragmentList {
6870                fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
6871                contains_boundary: false,
6872            },
6873        );
6874        fragments.insert(
6875            "parent-component".to_string(),
6876            FragmentList {
6877                fragments: vec![
6878                    WebUIFragment::raw("<child-component"),
6879                    WebUIFragment {
6880                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6881                            WebUIFragmentAttribute {
6882                                name: "title".into(),
6883                                template: "c-title".into(),
6884                                attr_start: true,
6885                                ..Default::default()
6886                            },
6887                        )),
6888                    },
6889                    WebUIFragment::raw(">"),
6890                    WebUIFragment::component("child-component"),
6891                    WebUIFragment::raw("</child-component>"),
6892                ],
6893
6894                contains_boundary: false,
6895            },
6896        );
6897        fragments.insert(
6898            "c-title".to_string(),
6899            FragmentList {
6900                fragments: vec![
6901                    WebUIFragment::raw("C("),
6902                    WebUIFragment::signal("title", false),
6903                    WebUIFragment::raw(")-"),
6904                    WebUIFragment::signal("cExtra", false),
6905                ],
6906                contains_boundary: false,
6907            },
6908        );
6909        fragments.insert(
6910            "child-component".to_string(),
6911            FragmentList {
6912                fragments: vec![
6913                    WebUIFragment::raw("<grandchild-component"),
6914                    WebUIFragment {
6915                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6916                            WebUIFragmentAttribute {
6917                                name: "title".into(),
6918                                value: "title".into(),
6919                                attr_start: true,
6920                                ..Default::default()
6921                            },
6922                        )),
6923                    },
6924                    WebUIFragment::raw(">"),
6925                    WebUIFragment::component("grandchild-component"),
6926                    WebUIFragment::raw("</grandchild-component>"),
6927                ],
6928
6929                contains_boundary: false,
6930            },
6931        );
6932        fragments.insert(
6933            "grandchild-component".to_string(),
6934            FragmentList {
6935                fragments: vec![
6936                    WebUIFragment::raw("<span>"),
6937                    WebUIFragment::signal("title", false),
6938                    WebUIFragment::raw("</span>"),
6939                ],
6940                contains_boundary: false,
6941            },
6942        );
6943        let protocol = WebUIProtocol::new(fragments);
6944        let state = test_json!({"p": "<p>", "cExtra": "x&y"});
6945        let mut writer = TestWriter::new();
6946        handle(
6947            &protocol,
6948            &state,
6949            &RenderOptions::new("index.html", "/"),
6950            &mut writer,
6951        )
6952        .unwrap();
6953        assert_eq!(
6954            writer.get_content(),
6955            "<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>"
6956        );
6957    }
6958
6959    #[test]
6960    fn test_for_loop_component_attr() {
6961        let mut fragments = HashMap::new();
6962        fragments.insert(
6963            "index.html".to_string(),
6964            FragmentList {
6965                fragments: vec![
6966                    WebUIFragment::raw("<parent-component"),
6967                    WebUIFragment {
6968                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6969                            WebUIFragmentAttribute {
6970                                name: "title".into(),
6971                                template: "parent-title-loop".into(),
6972                                attr_start: true,
6973                                ..Default::default()
6974                            },
6975                        )),
6976                    },
6977                    WebUIFragment::raw(">"),
6978                    WebUIFragment::component("parent-component"),
6979                    WebUIFragment::raw("</parent-component>"),
6980                ],
6981
6982                contains_boundary: false,
6983            },
6984        );
6985        fragments.insert(
6986            "parent-title-loop".to_string(),
6987            FragmentList {
6988                fragments: vec![
6989                    WebUIFragment::raw("Parent:"),
6990                    WebUIFragment::signal("who", false),
6991                ],
6992                contains_boundary: false,
6993            },
6994        );
6995        fragments.insert(
6996            "parent-component".to_string(),
6997            FragmentList {
6998                fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
6999                contains_boundary: false,
7000            },
7001        );
7002        fragments.insert(
7003            "child-loop".to_string(),
7004            FragmentList {
7005                fragments: vec![
7006                    WebUIFragment::raw("<child-component"),
7007                    WebUIFragment {
7008                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7009                            WebUIFragmentAttribute {
7010                                name: "title".into(),
7011                                template: "child-title-loop".into(),
7012                                attr_start: true,
7013                                ..Default::default()
7014                            },
7015                        )),
7016                    },
7017                    WebUIFragment::raw(">"),
7018                    WebUIFragment::component("child-component"),
7019                    WebUIFragment::raw("</child-component>"),
7020                ],
7021
7022                contains_boundary: false,
7023            },
7024        );
7025        fragments.insert(
7026            "child-title-loop".to_string(),
7027            FragmentList {
7028                fragments: vec![
7029                    WebUIFragment::raw("Hi "),
7030                    WebUIFragment::signal("item.name", false),
7031                    WebUIFragment::raw(" / "),
7032                    WebUIFragment::signal("title", false),
7033                ],
7034                contains_boundary: false,
7035            },
7036        );
7037        fragments.insert(
7038            "child-component".to_string(),
7039            FragmentList {
7040                fragments: vec![
7041                    WebUIFragment::raw("<span>"),
7042                    WebUIFragment::signal("title", false),
7043                    WebUIFragment::raw("</span>"),
7044                ],
7045                contains_boundary: false,
7046            },
7047        );
7048        let protocol = WebUIProtocol::new(fragments);
7049        let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
7050        let mut writer = TestWriter::new();
7051        handle(
7052            &protocol,
7053            &state,
7054            &RenderOptions::new("index.html", "/"),
7055            &mut writer,
7056        )
7057        .unwrap();
7058        assert_eq!(
7059            writer.get_content(),
7060            "<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>"
7061        );
7062    }
7063
7064    #[test]
7065    fn test_multiple_template_attrs() {
7066        let mut fragments = HashMap::new();
7067        fragments.insert(
7068            "index.html".to_string(),
7069            FragmentList {
7070                fragments: vec![
7071                    WebUIFragment::raw("<my-component"),
7072                    WebUIFragment {
7073                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7074                            WebUIFragmentAttribute {
7075                                name: "title".into(),
7076                                template: "attr-title".into(),
7077                                attr_start: true,
7078                                ..Default::default()
7079                            },
7080                        )),
7081                    },
7082                    WebUIFragment {
7083                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7084                            WebUIFragmentAttribute {
7085                                name: "data-title".into(),
7086                                template: "attr-data-title".into(),
7087                                attr_start: false,
7088                                ..Default::default()
7089                            },
7090                        )),
7091                    },
7092                    WebUIFragment {
7093                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7094                            WebUIFragmentAttribute {
7095                                name: "aria-label".into(),
7096                                template: "attr-aria-label".into(),
7097                                attr_start: false,
7098                                ..Default::default()
7099                            },
7100                        )),
7101                    },
7102                    WebUIFragment::raw(">"),
7103                    WebUIFragment::component("my-component"),
7104                    WebUIFragment::raw("</my-component>"),
7105                ],
7106
7107                contains_boundary: false,
7108            },
7109        );
7110        fragments.insert(
7111            "attr-title".to_string(),
7112            FragmentList {
7113                fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
7114                contains_boundary: false,
7115            },
7116        );
7117        fragments.insert(
7118            "attr-data-title".to_string(),
7119            FragmentList {
7120                fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
7121                contains_boundary: false,
7122            },
7123        );
7124        fragments.insert(
7125            "attr-aria-label".to_string(),
7126            FragmentList {
7127                fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
7128                contains_boundary: false,
7129            },
7130        );
7131        fragments.insert(
7132            "my-component".to_string(),
7133            FragmentList {
7134                fragments: vec![
7135                    WebUIFragment::raw("<span>"),
7136                    WebUIFragment::signal("title", false),
7137                    WebUIFragment::raw("|"),
7138                    WebUIFragment::signal("dataTitle", false),
7139                    WebUIFragment::raw("|"),
7140                    WebUIFragment::signal("ariaLabel", false),
7141                    WebUIFragment::raw("</span>"),
7142                ],
7143                contains_boundary: false,
7144            },
7145        );
7146        let protocol = WebUIProtocol::new(fragments);
7147        let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
7148        let mut writer = TestWriter::new();
7149        handle(
7150            &protocol,
7151            &state,
7152            &RenderOptions::new("index.html", "/"),
7153            &mut writer,
7154        )
7155        .unwrap();
7156        assert_eq!(
7157            writer.get_content(),
7158            "<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>"
7159        );
7160    }
7161
7162    #[test]
7163    fn test_attr_priority_over_global() {
7164        let mut fragments = HashMap::new();
7165        fragments.insert(
7166            "index.html".to_string(),
7167            FragmentList {
7168                fragments: vec![
7169                    WebUIFragment::raw("<my-component"),
7170                    WebUIFragment {
7171                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7172                            WebUIFragmentAttribute {
7173                                name: "title".into(),
7174                                value: "Attribute Title".into(),
7175                                raw_value: true,
7176                                attr_start: true,
7177                                ..Default::default()
7178                            },
7179                        )),
7180                    },
7181                    WebUIFragment::raw(">"),
7182                    WebUIFragment::component("my-component"),
7183                    WebUIFragment::raw("</my-component>"),
7184                ],
7185
7186                contains_boundary: false,
7187            },
7188        );
7189        fragments.insert(
7190            "my-component".to_string(),
7191            FragmentList {
7192                fragments: vec![
7193                    WebUIFragment::raw("<span>"),
7194                    WebUIFragment::signal("title", false),
7195                    WebUIFragment::raw("</span>"),
7196                ],
7197                contains_boundary: false,
7198            },
7199        );
7200        let protocol = WebUIProtocol::new(fragments);
7201        let state = test_json!({"title": "Global Title"});
7202        let mut writer = TestWriter::new();
7203        handle(
7204            &protocol,
7205            &state,
7206            &RenderOptions::new("index.html", "/"),
7207            &mut writer,
7208        )
7209        .unwrap();
7210        assert_eq!(
7211            writer.get_content(),
7212            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
7213        );
7214    }
7215
7216    #[test]
7217    fn test_attr_priority_over_local_and_global() {
7218        let mut fragments = HashMap::new();
7219        fragments.insert(
7220            "index.html".to_string(),
7221            FragmentList {
7222                fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
7223                contains_boundary: false,
7224            },
7225        );
7226        fragments.insert(
7227            "loop".to_string(),
7228            FragmentList {
7229                fragments: vec![
7230                    WebUIFragment::raw("<my-component"),
7231                    WebUIFragment {
7232                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7233                            WebUIFragmentAttribute {
7234                                name: "title".into(),
7235                                value: "Attribute Title".into(),
7236                                raw_value: true,
7237                                attr_start: true,
7238                                ..Default::default()
7239                            },
7240                        )),
7241                    },
7242                    WebUIFragment::raw(">"),
7243                    WebUIFragment::component("my-component"),
7244                    WebUIFragment::raw("</my-component>"),
7245                ],
7246
7247                contains_boundary: false,
7248            },
7249        );
7250        fragments.insert(
7251            "my-component".to_string(),
7252            FragmentList {
7253                fragments: vec![
7254                    WebUIFragment::raw("<span>"),
7255                    WebUIFragment::signal("title", false),
7256                    WebUIFragment::raw("</span>"),
7257                ],
7258                contains_boundary: false,
7259            },
7260        );
7261        let protocol = WebUIProtocol::new(fragments);
7262        let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
7263        let mut writer = TestWriter::new();
7264        handle(
7265            &protocol,
7266            &state,
7267            &RenderOptions::new("index.html", "/"),
7268            &mut writer,
7269        )
7270        .unwrap();
7271        assert_eq!(
7272            writer.get_content(),
7273            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
7274        );
7275    }
7276
7277    #[test]
7278    fn test_boolean_attr_first_component_attr() {
7279        let mut fragments = HashMap::new();
7280        fragments.insert(
7281            "index.html".to_string(),
7282            FragmentList {
7283                fragments: vec![
7284                    WebUIFragment::raw("<my-component"),
7285                    WebUIFragment {
7286                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7287                            WebUIFragmentAttribute {
7288                                name: "disabled".into(),
7289                                attr_start: true,
7290                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7291                                ..Default::default()
7292                            },
7293                        )),
7294                    },
7295                    WebUIFragment {
7296                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7297                            WebUIFragmentAttribute {
7298                                name: "label".into(),
7299                                value: "Component Label".into(),
7300                                raw_value: true,
7301                                attr_start: false,
7302                                ..Default::default()
7303                            },
7304                        )),
7305                    },
7306                    WebUIFragment::raw(">"),
7307                    WebUIFragment::component("my-component"),
7308                    WebUIFragment::raw("</my-component>"),
7309                ],
7310
7311                contains_boundary: false,
7312            },
7313        );
7314        fragments.insert(
7315            "my-component".to_string(),
7316            FragmentList {
7317                fragments: vec![
7318                    WebUIFragment::if_cond(
7319                        ConditionExpr::identifier("disabled"),
7320                        "disabledTemplate",
7321                    ),
7322                    WebUIFragment::raw("<span>"),
7323                    WebUIFragment::signal("label", false),
7324                    WebUIFragment::raw("</span>"),
7325                ],
7326                contains_boundary: false,
7327            },
7328        );
7329        fragments.insert(
7330            "disabledTemplate".to_string(),
7331            FragmentList {
7332                fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
7333                contains_boundary: false,
7334            },
7335        );
7336        let protocol = WebUIProtocol::new(fragments);
7337        let state = test_json!({"isDisabled": true});
7338        let mut writer = TestWriter::new();
7339        handle(
7340            &protocol,
7341            &state,
7342            &RenderOptions::new("index.html", "/"),
7343            &mut writer,
7344        )
7345        .unwrap();
7346        assert_eq!(
7347            writer.get_content(),
7348            "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
7349        );
7350    }
7351
7352    #[test]
7353    fn test_hyphenated_attr_camelcase() {
7354        let mut fragments = HashMap::new();
7355        fragments.insert(
7356            "index.html".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: "key-hyphen".into(),
7364                                value: "Local Value".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("keyHyphen", false),
7385                    WebUIFragment::raw("</span>"),
7386                ],
7387                contains_boundary: false,
7388            },
7389        );
7390        let protocol = WebUIProtocol::new(fragments);
7391        let state = test_json!({"keyHyphen": "Global Value"});
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 key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
7403        );
7404    }
7405
7406    #[test]
7407    fn test_skipped_component_attrs() {
7408        // Skipped attributes: class, style, role, data-*, aria-*
7409        // Plus framework-specific prefixes/names that the parser marks with attr_skip.
7410        // These render on the HTML element but are NOT passed into component attribute state.
7411        let mut fragments = HashMap::new();
7412        fragments.insert(
7413            "index.html".to_string(),
7414            FragmentList {
7415                fragments: vec![
7416                    WebUIFragment::raw("<test-component"),
7417                    // Skipped: class
7418                    WebUIFragment {
7419                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7420                            WebUIFragmentAttribute {
7421                                name: "class".into(),
7422                                value: "skippedClass".into(),
7423                                attr_start: true,
7424                                attr_skip: true,
7425                                ..Default::default()
7426                            },
7427                        )),
7428                    },
7429                    // Skipped: style
7430                    WebUIFragment {
7431                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7432                            WebUIFragmentAttribute {
7433                                name: "style".into(),
7434                                value: "skippedStyle".into(),
7435                                attr_skip: true,
7436                                ..Default::default()
7437                            },
7438                        )),
7439                    },
7440                    // Skipped: role
7441                    WebUIFragment {
7442                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7443                            WebUIFragmentAttribute {
7444                                name: "role".into(),
7445                                value: "skippedRole".into(),
7446                                attr_skip: true,
7447                                ..Default::default()
7448                            },
7449                        )),
7450                    },
7451                    // Skipped: data-testid (data-* prefix)
7452                    WebUIFragment {
7453                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7454                            WebUIFragmentAttribute {
7455                                name: "data-testid".into(),
7456                                value: "skippedDataTestid".into(),
7457                                attr_skip: true,
7458                                ..Default::default()
7459                            },
7460                        )),
7461                    },
7462                    // Skipped: aria-label (aria-* prefix)
7463                    WebUIFragment {
7464                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7465                            WebUIFragmentAttribute {
7466                                name: "aria-label".into(),
7467                                value: "skippedAriaLabel".into(),
7468                                attr_skip: true,
7469                                ..Default::default()
7470                            },
7471                        )),
7472                    },
7473                    // NOT skipped: title
7474                    WebUIFragment {
7475                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7476                            WebUIFragmentAttribute {
7477                                name: "title".into(),
7478                                value: "title".into(),
7479                                ..Default::default()
7480                            },
7481                        )),
7482                    },
7483                    WebUIFragment::raw(">"),
7484                    WebUIFragment::component("test-component"),
7485                    WebUIFragment::raw("</test-component>"),
7486                ],
7487
7488                contains_boundary: false,
7489            },
7490        );
7491        fragments.insert(
7492            "test-component".to_string(),
7493            FragmentList {
7494                fragments: vec![
7495                    WebUIFragment::raw("<span>"),
7496                    WebUIFragment::signal("title", false),
7497                    WebUIFragment::raw("-"),
7498                    WebUIFragment::signal("class", false),
7499                    WebUIFragment::raw("-"),
7500                    WebUIFragment::signal("style", false),
7501                    WebUIFragment::raw("-"),
7502                    WebUIFragment::signal("role", false),
7503                    WebUIFragment::raw("-"),
7504                    WebUIFragment::signal("dataTestid", false),
7505                    WebUIFragment::raw("-"),
7506                    WebUIFragment::signal("ariaLabel", false),
7507                    WebUIFragment::raw("</span>"),
7508                ],
7509                contains_boundary: false,
7510            },
7511        );
7512        let protocol = WebUIProtocol::new(fragments);
7513        let state = test_json!({
7514            "title": "Hello",
7515            "skippedClass": "my-class",
7516            "skippedStyle": "color:red",
7517            "skippedRole": "button",
7518            "skippedDataTestid": "test-id",
7519            "skippedAriaLabel": "label-text"
7520        });
7521        let mut writer = TestWriter::new();
7522        handle(
7523            &protocol,
7524            &state,
7525            &RenderOptions::new("index.html", "/"),
7526            &mut writer,
7527        )
7528        .unwrap();
7529        // Skipped attrs render on the element but their values are NOT accessible inside the component.
7530        // The component's signals for skipped attrs resolve to empty strings.
7531        // Only "title" (non-skipped) is accessible.
7532        assert_eq!(
7533            writer.get_content(),
7534            "<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>"
7535        );
7536    }
7537
7538    // ── GROUP 7: Attribute Inheritance ─────────────────────────────────
7539
7540    #[test]
7541    fn test_attr_inherit_parent_to_child() {
7542        let mut fragments = HashMap::new();
7543        fragments.insert(
7544            "index.html".to_string(),
7545            FragmentList {
7546                fragments: vec![
7547                    WebUIFragment::raw("<parent-component"),
7548                    WebUIFragment {
7549                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7550                            WebUIFragmentAttribute {
7551                                name: "title".into(),
7552                                value: "Parent Title".into(),
7553                                raw_value: true,
7554                                attr_start: true,
7555                                ..Default::default()
7556                            },
7557                        )),
7558                    },
7559                    WebUIFragment::raw(">"),
7560                    WebUIFragment::component("parent-component"),
7561                    WebUIFragment::raw("</parent-component>"),
7562                ],
7563
7564                contains_boundary: false,
7565            },
7566        );
7567        fragments.insert(
7568            "parent-component".to_string(),
7569            FragmentList {
7570                fragments: vec![
7571                    WebUIFragment::raw("<h1>"),
7572                    WebUIFragment::signal("title", false),
7573                    WebUIFragment::raw("</h1><child-component"),
7574                    WebUIFragment {
7575                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7576                            WebUIFragmentAttribute {
7577                                name: "title".into(),
7578                                value: "title".into(),
7579                                attr_start: true,
7580                                ..Default::default()
7581                            },
7582                        )),
7583                    },
7584                    WebUIFragment::raw(">"),
7585                    WebUIFragment::component("child-component"),
7586                    WebUIFragment::raw("</child-component>"),
7587                ],
7588
7589                contains_boundary: false,
7590            },
7591        );
7592        fragments.insert(
7593            "child-component".to_string(),
7594            FragmentList {
7595                fragments: vec![
7596                    WebUIFragment::raw("<h2>"),
7597                    WebUIFragment::signal("title", false),
7598                    WebUIFragment::raw("</h2>"),
7599                ],
7600                contains_boundary: false,
7601            },
7602        );
7603        let protocol = WebUIProtocol::new(fragments);
7604        let state = test_json!({});
7605        let mut writer = TestWriter::new();
7606        handle(
7607            &protocol,
7608            &state,
7609            &RenderOptions::new("index.html", "/"),
7610            &mut writer,
7611        )
7612        .unwrap();
7613        assert_eq!(
7614            writer.get_content(),
7615            "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
7616        );
7617    }
7618
7619    #[test]
7620    fn test_attr_inherit_deep() {
7621        let mut fragments = HashMap::new();
7622        fragments.insert(
7623            "index.html".to_string(),
7624            FragmentList {
7625                fragments: vec![
7626                    WebUIFragment::raw("<parent-component"),
7627                    WebUIFragment {
7628                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7629                            WebUIFragmentAttribute {
7630                                name: "title".into(),
7631                                value: "Parent Title".into(),
7632                                raw_value: true,
7633                                attr_start: true,
7634                                ..Default::default()
7635                            },
7636                        )),
7637                    },
7638                    WebUIFragment::raw(">"),
7639                    WebUIFragment::component("parent-component"),
7640                    WebUIFragment::raw("</parent-component>"),
7641                ],
7642
7643                contains_boundary: false,
7644            },
7645        );
7646        fragments.insert(
7647            "parent-component".to_string(),
7648            FragmentList {
7649                fragments: vec![
7650                    WebUIFragment::raw("<child-component"),
7651                    WebUIFragment {
7652                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7653                            WebUIFragmentAttribute {
7654                                name: "title".into(),
7655                                value: "Child Title".into(),
7656                                raw_value: true,
7657                                attr_start: true,
7658                                ..Default::default()
7659                            },
7660                        )),
7661                    },
7662                    WebUIFragment::raw(">"),
7663                    WebUIFragment::component("child-component"),
7664                    WebUIFragment::raw("</child-component>"),
7665                ],
7666
7667                contains_boundary: false,
7668            },
7669        );
7670        fragments.insert(
7671            "child-component".to_string(),
7672            FragmentList {
7673                fragments: vec![
7674                    WebUIFragment::raw("<grandchild-component"),
7675                    WebUIFragment {
7676                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7677                            WebUIFragmentAttribute {
7678                                name: "title".into(),
7679                                value: "title".into(),
7680                                attr_start: true,
7681                                ..Default::default()
7682                            },
7683                        )),
7684                    },
7685                    WebUIFragment::raw(">"),
7686                    WebUIFragment::component("grandchild-component"),
7687                    WebUIFragment::raw("</grandchild-component>"),
7688                ],
7689
7690                contains_boundary: false,
7691            },
7692        );
7693        fragments.insert(
7694            "grandchild-component".to_string(),
7695            FragmentList {
7696                fragments: vec![
7697                    WebUIFragment::raw("<h3>"),
7698                    WebUIFragment::signal("title", false),
7699                    WebUIFragment::raw("</h3>"),
7700                ],
7701                contains_boundary: false,
7702            },
7703        );
7704        let protocol = WebUIProtocol::new(fragments);
7705        let state = test_json!({});
7706        let mut writer = TestWriter::new();
7707        handle(
7708            &protocol,
7709            &state,
7710            &RenderOptions::new("index.html", "/"),
7711            &mut writer,
7712        )
7713        .unwrap();
7714        assert_eq!(
7715            writer.get_content(),
7716            "<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>"
7717        );
7718    }
7719
7720    #[test]
7721    fn test_complex_attr_access() {
7722        let mut fragments = HashMap::new();
7723        fragments.insert(
7724            "index.html".to_string(),
7725            FragmentList {
7726                fragments: vec![
7727                    WebUIFragment::raw("<my-component"),
7728                    WebUIFragment {
7729                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7730                            WebUIFragmentAttribute {
7731                                name: ":item".into(),
7732                                value: "complexItem".into(),
7733                                attr_start: true,
7734                                complex: true,
7735                                ..Default::default()
7736                            },
7737                        )),
7738                    },
7739                    WebUIFragment::raw(">"),
7740                    WebUIFragment::component("my-component"),
7741                    WebUIFragment::raw("</my-component>"),
7742                ],
7743
7744                contains_boundary: false,
7745            },
7746        );
7747        fragments.insert(
7748            "my-component".to_string(),
7749            FragmentList {
7750                fragments: vec![
7751                    WebUIFragment::raw("<span>"),
7752                    WebUIFragment::signal("item.foo", false),
7753                    WebUIFragment::raw("</span><p>"),
7754                    WebUIFragment::signal("item.bar", false),
7755                    WebUIFragment::raw("</p>"),
7756                ],
7757                contains_boundary: false,
7758            },
7759        );
7760        let protocol = WebUIProtocol::new(fragments);
7761        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
7762        let mut writer = TestWriter::new();
7763        handle(
7764            &protocol,
7765            &state,
7766            &RenderOptions::new("index.html", "/"),
7767            &mut writer,
7768        )
7769        .unwrap();
7770        assert_eq!(
7771            writer.get_content(),
7772            "<my-component><span>1</span><p>true</p></my-component>"
7773        );
7774    }
7775
7776    #[test]
7777    fn test_complex_attr_for_loop() {
7778        let mut fragments = HashMap::new();
7779        fragments.insert(
7780            "index.html".to_string(),
7781            FragmentList {
7782                fragments: vec![WebUIFragment::for_loop(
7783                    "item",
7784                    "list.items",
7785                    "listTemplate",
7786                )],
7787                contains_boundary: false,
7788            },
7789        );
7790        fragments.insert(
7791            "listTemplate".to_string(),
7792            FragmentList {
7793                fragments: vec![
7794                    WebUIFragment {
7795                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7796                            WebUIFragmentAttribute {
7797                                name: ":item".into(),
7798                                value: "item".into(),
7799                                attr_start: true,
7800                                complex: true,
7801                                ..Default::default()
7802                            },
7803                        )),
7804                    },
7805                    WebUIFragment::component("item_component"),
7806                ],
7807
7808                contains_boundary: false,
7809            },
7810        );
7811        fragments.insert(
7812            "item_component".to_string(),
7813            FragmentList {
7814                fragments: vec![
7815                    WebUIFragment::raw("<span>"),
7816                    WebUIFragment::signal("item.name", false),
7817                    WebUIFragment::raw("</span>"),
7818                ],
7819                contains_boundary: false,
7820            },
7821        );
7822        let protocol = WebUIProtocol::new(fragments);
7823        let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
7824        let mut writer = TestWriter::new();
7825        handle(
7826            &protocol,
7827            &state,
7828            &RenderOptions::new("index.html", "/"),
7829            &mut writer,
7830        )
7831        .unwrap();
7832        assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
7833    }
7834
7835    #[test]
7836    fn test_complex_attr_nested_for() {
7837        let mut fragments = HashMap::new();
7838        fragments.insert(
7839            "index.html".to_string(),
7840            FragmentList {
7841                fragments: vec![WebUIFragment::for_loop(
7842                    "outer",
7843                    "data.outer",
7844                    "outerTemplate",
7845                )],
7846                contains_boundary: false,
7847            },
7848        );
7849        fragments.insert(
7850            "outerTemplate".to_string(),
7851            FragmentList {
7852                fragments: vec![WebUIFragment::for_loop(
7853                    "middle",
7854                    "outer.middle",
7855                    "middleTemplate",
7856                )],
7857                contains_boundary: false,
7858            },
7859        );
7860        fragments.insert(
7861            "middleTemplate".to_string(),
7862            FragmentList {
7863                fragments: vec![WebUIFragment::for_loop(
7864                    "inner",
7865                    "middle.inner",
7866                    "innerTemplate",
7867                )],
7868                contains_boundary: false,
7869            },
7870        );
7871        fragments.insert(
7872            "innerTemplate".to_string(),
7873            FragmentList {
7874                fragments: vec![
7875                    WebUIFragment::raw("<card"),
7876                    WebUIFragment {
7877                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7878                            WebUIFragmentAttribute {
7879                                name: ":outer".into(),
7880                                value: "outer".into(),
7881                                attr_start: true,
7882                                complex: true,
7883                                ..Default::default()
7884                            },
7885                        )),
7886                    },
7887                    WebUIFragment {
7888                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7889                            WebUIFragmentAttribute {
7890                                name: ":middle".into(),
7891                                value: "middle".into(),
7892                                attr_start: false,
7893                                complex: true,
7894                                ..Default::default()
7895                            },
7896                        )),
7897                    },
7898                    WebUIFragment {
7899                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7900                            WebUIFragmentAttribute {
7901                                name: ":inner".into(),
7902                                value: "inner".into(),
7903                                attr_start: false,
7904                                complex: true,
7905                                ..Default::default()
7906                            },
7907                        )),
7908                    },
7909                    WebUIFragment::raw(">"),
7910                    WebUIFragment::component("card_component"),
7911                    WebUIFragment::raw("</card>"),
7912                ],
7913
7914                contains_boundary: false,
7915            },
7916        );
7917        fragments.insert(
7918            "card_component".to_string(),
7919            FragmentList {
7920                fragments: vec![
7921                    WebUIFragment::raw("<p>"),
7922                    WebUIFragment::signal("outer.label", false),
7923                    WebUIFragment::raw(" / "),
7924                    WebUIFragment::signal("middle.label", false),
7925                    WebUIFragment::raw(" / "),
7926                    WebUIFragment::signal("inner.label", false),
7927                    WebUIFragment::raw("</p>"),
7928                ],
7929                contains_boundary: false,
7930            },
7931        );
7932        let protocol = WebUIProtocol::new(fragments);
7933        let state = test_json!({"data": {"outer": [
7934            {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
7935            {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
7936        ]}});
7937        let mut writer = TestWriter::new();
7938        handle(
7939            &protocol,
7940            &state,
7941            &RenderOptions::new("index.html", "/"),
7942            &mut writer,
7943        )
7944        .unwrap();
7945        assert_eq!(
7946            writer.get_content(),
7947            "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
7948        );
7949    }
7950
7951    // ── GROUP 8: Boolean Component State ──────────────────────────────
7952
7953    #[test]
7954    fn test_bool_component_state_true() {
7955        let mut fragments = HashMap::new();
7956        fragments.insert(
7957            "index.html".to_string(),
7958            FragmentList {
7959                fragments: vec![
7960                    WebUIFragment::raw("<my-component"),
7961                    WebUIFragment {
7962                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7963                            WebUIFragmentAttribute {
7964                                name: "disabled".into(),
7965                                attr_start: true,
7966                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7967                                ..Default::default()
7968                            },
7969                        )),
7970                    },
7971                    WebUIFragment::raw(">"),
7972                    WebUIFragment::component("my-component"),
7973                    WebUIFragment::raw("</my-component>"),
7974                ],
7975
7976                contains_boundary: false,
7977            },
7978        );
7979        fragments.insert(
7980            "my-component".to_string(),
7981            FragmentList {
7982                fragments: vec![
7983                    WebUIFragment::if_cond(
7984                        ConditionExpr::identifier("disabled"),
7985                        "disabledTemplate",
7986                    ),
7987                    WebUIFragment::if_cond(
7988                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7989                        "enabledTemplate",
7990                    ),
7991                ],
7992                contains_boundary: false,
7993            },
7994        );
7995        fragments.insert(
7996            "disabledTemplate".to_string(),
7997            FragmentList {
7998                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
7999                contains_boundary: false,
8000            },
8001        );
8002        fragments.insert(
8003            "enabledTemplate".to_string(),
8004            FragmentList {
8005                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
8006                contains_boundary: false,
8007            },
8008        );
8009        let protocol = WebUIProtocol::new(fragments);
8010        let state = test_json!({"isDisabled": true});
8011        let mut writer = TestWriter::new();
8012        handle(
8013            &protocol,
8014            &state,
8015            &RenderOptions::new("index.html", "/"),
8016            &mut writer,
8017        )
8018        .unwrap();
8019        assert_eq!(
8020            writer.get_content(),
8021            "<my-component disabled><span>Disabled</span></my-component>"
8022        );
8023    }
8024
8025    #[test]
8026    fn test_bool_component_state_false() {
8027        let mut fragments = HashMap::new();
8028        fragments.insert(
8029            "index.html".to_string(),
8030            FragmentList {
8031                fragments: vec![
8032                    WebUIFragment::raw("<my-component"),
8033                    WebUIFragment {
8034                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8035                            WebUIFragmentAttribute {
8036                                name: "disabled".into(),
8037                                attr_start: true,
8038                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8039                                ..Default::default()
8040                            },
8041                        )),
8042                    },
8043                    WebUIFragment::raw(">"),
8044                    WebUIFragment::component("my-component"),
8045                    WebUIFragment::raw("</my-component>"),
8046                ],
8047
8048                contains_boundary: false,
8049            },
8050        );
8051        fragments.insert(
8052            "my-component".to_string(),
8053            FragmentList {
8054                fragments: vec![
8055                    WebUIFragment::if_cond(
8056                        ConditionExpr::identifier("disabled"),
8057                        "disabledTemplate",
8058                    ),
8059                    WebUIFragment::if_cond(
8060                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8061                        "enabledTemplate",
8062                    ),
8063                ],
8064                contains_boundary: false,
8065            },
8066        );
8067        fragments.insert(
8068            "disabledTemplate".to_string(),
8069            FragmentList {
8070                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
8071                contains_boundary: false,
8072            },
8073        );
8074        fragments.insert(
8075            "enabledTemplate".to_string(),
8076            FragmentList {
8077                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
8078                contains_boundary: false,
8079            },
8080        );
8081        let protocol = WebUIProtocol::new(fragments);
8082        let state = test_json!({"isDisabled": false});
8083        let mut writer = TestWriter::new();
8084        handle(
8085            &protocol,
8086            &state,
8087            &RenderOptions::new("index.html", "/"),
8088            &mut writer,
8089        )
8090        .unwrap();
8091        assert_eq!(
8092            writer.get_content(),
8093            "<my-component><span>Enabled</span></my-component>"
8094        );
8095    }
8096
8097    #[test]
8098    fn test_bool_component_state_forward() {
8099        let mut fragments = HashMap::new();
8100        fragments.insert(
8101            "index.html".to_string(),
8102            FragmentList {
8103                fragments: vec![
8104                    WebUIFragment::raw("<parent-component"),
8105                    WebUIFragment {
8106                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8107                            WebUIFragmentAttribute {
8108                                name: "disabled".into(),
8109                                attr_start: true,
8110                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8111                                ..Default::default()
8112                            },
8113                        )),
8114                    },
8115                    WebUIFragment::raw(">"),
8116                    WebUIFragment::component("parent-component"),
8117                    WebUIFragment::raw("</parent-component>"),
8118                ],
8119
8120                contains_boundary: false,
8121            },
8122        );
8123        fragments.insert(
8124            "parent-component".to_string(),
8125            FragmentList {
8126                fragments: vec![
8127                    WebUIFragment::if_cond(
8128                        ConditionExpr::identifier("disabled"),
8129                        "parentDisabledTemplate",
8130                    ),
8131                    WebUIFragment::raw("<child-component"),
8132                    WebUIFragment {
8133                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8134                            WebUIFragmentAttribute {
8135                                name: "disabled".into(),
8136                                attr_start: true,
8137                                condition_tree: Some(ConditionExpr::identifier("disabled")),
8138                                ..Default::default()
8139                            },
8140                        )),
8141                    },
8142                    WebUIFragment::raw(">"),
8143                    WebUIFragment::component("child-component"),
8144                    WebUIFragment::raw("</child-component>"),
8145                ],
8146
8147                contains_boundary: false,
8148            },
8149        );
8150        fragments.insert(
8151            "parentDisabledTemplate".to_string(),
8152            FragmentList {
8153                fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
8154                contains_boundary: false,
8155            },
8156        );
8157        fragments.insert(
8158            "child-component".to_string(),
8159            FragmentList {
8160                fragments: vec![
8161                    WebUIFragment::if_cond(
8162                        ConditionExpr::identifier("disabled"),
8163                        "childDisabledTemplate",
8164                    ),
8165                    WebUIFragment::if_cond(
8166                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8167                        "childEnabledTemplate",
8168                    ),
8169                ],
8170                contains_boundary: false,
8171            },
8172        );
8173        fragments.insert(
8174            "childDisabledTemplate".to_string(),
8175            FragmentList {
8176                fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
8177                contains_boundary: false,
8178            },
8179        );
8180        fragments.insert(
8181            "childEnabledTemplate".to_string(),
8182            FragmentList {
8183                fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
8184                contains_boundary: false,
8185            },
8186        );
8187
8188        // Test case 1: isDisabled = true
8189        {
8190            let protocol = WebUIProtocol::new(fragments.clone());
8191            let state = test_json!({"isDisabled": true});
8192            let mut writer = TestWriter::new();
8193            handle(
8194                &protocol,
8195                &state,
8196                &RenderOptions::new("index.html", "/"),
8197                &mut writer,
8198            )
8199            .unwrap();
8200            assert_eq!(
8201                writer.get_content(),
8202                "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
8203            );
8204        }
8205
8206        // Test case 2: isDisabled = false
8207        {
8208            let protocol = WebUIProtocol::new(fragments.clone());
8209            let state = test_json!({"isDisabled": false});
8210            let mut writer = TestWriter::new();
8211            handle(
8212                &protocol,
8213                &state,
8214                &RenderOptions::new("index.html", "/"),
8215                &mut writer,
8216            )
8217            .unwrap();
8218            assert_eq!(
8219                writer.get_content(),
8220                "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
8221            );
8222        }
8223    }
8224
8225    // ── GROUP 9: Hydration (SKIP) ─────────────────────────────────────
8226
8227    // TODO: test_hydration - requires FAST handler plugin integration; see plugin/fast.rs
8228
8229    // ── Component tests ──────────────────────────────────────────────
8230
8231    #[test]
8232    fn test_component_with_template() {
8233        let mut fragments = HashMap::new();
8234        fragments.insert(
8235            "index.html".to_string(),
8236            FragmentList {
8237                fragments: vec![
8238                    WebUIFragment::raw("<custom-element>"),
8239                    WebUIFragment::component("custom-element"),
8240                    WebUIFragment::raw("</custom-element>"),
8241                ],
8242                contains_boundary: false,
8243            },
8244        );
8245        fragments.insert(
8246            "custom-element".to_string(),
8247            FragmentList {
8248                fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
8249                contains_boundary: false,
8250            },
8251        );
8252        let protocol = WebUIProtocol::new(fragments);
8253        let state = test_json!({});
8254        let mut writer = TestWriter::new();
8255        handle(
8256            &protocol,
8257            &state,
8258            &RenderOptions::new("index.html", "/"),
8259            &mut writer,
8260        )
8261        .unwrap();
8262        assert_eq!(
8263            writer.get_content(),
8264            "<custom-element><div>Custom Element</div></custom-element>"
8265        );
8266        assert!(writer.is_ended());
8267    }
8268
8269    #[test]
8270    fn test_component_with_slots() {
8271        let mut fragments = HashMap::new();
8272        fragments.insert(
8273            "index.html".to_string(),
8274            FragmentList {
8275                fragments: vec![
8276                    WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
8277                    WebUIFragment::component("custom-element"),
8278                    WebUIFragment::raw("Hello World</custom-element>"),
8279                ],
8280                contains_boundary: false,
8281            },
8282        );
8283        fragments.insert(
8284            "custom-element".to_string(),
8285            FragmentList {
8286                fragments: vec![WebUIFragment::raw("<slot></slot>")],
8287                contains_boundary: false,
8288            },
8289        );
8290        let protocol = WebUIProtocol::new(fragments);
8291        let state = test_json!({});
8292        let mut writer = TestWriter::new();
8293        handle(
8294            &protocol,
8295            &state,
8296            &RenderOptions::new("index.html", "/"),
8297            &mut writer,
8298        )
8299        .unwrap();
8300        assert_eq!(
8301            writer.get_content(),
8302            "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
8303        );
8304        assert!(writer.is_ended());
8305    }
8306
8307    #[test]
8308    fn test_multiple_nested_components() {
8309        let mut fragments = HashMap::new();
8310        fragments.insert(
8311            "index.html".to_string(),
8312            FragmentList {
8313                fragments: vec![
8314                    WebUIFragment::raw("<div>"),
8315                    WebUIFragment::for_loop("item", "items", "templateRepeat"),
8316                    WebUIFragment::raw("</div>"),
8317                ],
8318                contains_boundary: false,
8319            },
8320        );
8321        fragments.insert(
8322            "custom-button".to_string(),
8323            FragmentList {
8324                fragments: vec![WebUIFragment::raw("<slot></slot>")],
8325                contains_boundary: false,
8326            },
8327        );
8328        fragments.insert(
8329            "custom-element".to_string(),
8330            FragmentList {
8331                fragments: vec![
8332                    WebUIFragment::raw("<custom-child>"),
8333                    WebUIFragment::component("custom-child"),
8334                    WebUIFragment::raw("</custom-child><slot></slot>"),
8335                ],
8336                contains_boundary: false,
8337            },
8338        );
8339        fragments.insert(
8340            "custom-child".to_string(),
8341            FragmentList {
8342                fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
8343                contains_boundary: false,
8344            },
8345        );
8346        fragments.insert(
8347            "templateRepeat".to_string(),
8348            FragmentList {
8349                fragments: vec![
8350                    WebUIFragment::raw("<custom-element>"),
8351                    WebUIFragment::component("custom-element"),
8352                    WebUIFragment::raw("<custom-button>"),
8353                    WebUIFragment::component("custom-button"),
8354                    WebUIFragment::raw("Ok</custom-button></custom-element>"),
8355                ],
8356                contains_boundary: false,
8357            },
8358        );
8359        let protocol = WebUIProtocol::new(fragments);
8360        let state = test_json!({"items": [{"name": "Item1"}]});
8361        let mut writer = TestWriter::new();
8362        handle(
8363            &protocol,
8364            &state,
8365            &RenderOptions::new("index.html", "/"),
8366            &mut writer,
8367        )
8368        .unwrap();
8369        assert_eq!(
8370            writer.get_content(),
8371            "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
8372        );
8373        assert!(writer.is_ended());
8374    }
8375
8376    // ── Conditional tests ────────────────────────────────────────────
8377
8378    #[test]
8379    fn test_if_with_binary_expression() {
8380        let mut fragments = HashMap::new();
8381        fragments.insert(
8382            "index.html".to_string(),
8383            FragmentList {
8384                fragments: vec![
8385                    WebUIFragment::raw("<div>"),
8386                    WebUIFragment::if_cond(
8387                        ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
8388                        "if-1",
8389                    ),
8390                    WebUIFragment::raw("</div>"),
8391                ],
8392                contains_boundary: false,
8393            },
8394        );
8395        fragments.insert(
8396            "if-1".to_string(),
8397            FragmentList {
8398                fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
8399                contains_boundary: false,
8400            },
8401        );
8402        let protocol = WebUIProtocol::new(fragments);
8403
8404        // True case: x = 10 > 5
8405        let state_true = test_json!({"x": 10});
8406        let mut writer_true = TestWriter::new();
8407        handle(
8408            &protocol,
8409            &state_true,
8410            &RenderOptions::new("index.html", "/"),
8411            &mut writer_true,
8412        )
8413        .unwrap();
8414        assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
8415
8416        // False case: x = 1 <= 5
8417        let state_false = test_json!({"x": 1});
8418        let mut writer_false = TestWriter::new();
8419        handle(
8420            &protocol,
8421            &state_false,
8422            &RenderOptions::new("index.html", "/"),
8423            &mut writer_false,
8424        )
8425        .unwrap();
8426        assert_eq!(writer_false.get_content(), "<div></div>");
8427    }
8428
8429    #[test]
8430    fn test_for_if_overlapping_local_state() {
8431        let mut fragments = HashMap::new();
8432        fragments.insert(
8433            "index.html".to_string(),
8434            FragmentList {
8435                fragments: vec![
8436                    WebUIFragment::raw("<div>"),
8437                    WebUIFragment::for_loop("item", "items", "template1"),
8438                    WebUIFragment::raw("</div>"),
8439                ],
8440                contains_boundary: false,
8441            },
8442        );
8443        fragments.insert(
8444            "template1".to_string(),
8445            FragmentList {
8446                fragments: vec![
8447                    WebUIFragment::raw("<div>"),
8448                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8449                    WebUIFragment::raw("</div>"),
8450                ],
8451                contains_boundary: false,
8452            },
8453        );
8454        fragments.insert(
8455            "ifBlock".to_string(),
8456            FragmentList {
8457                fragments: vec![
8458                    WebUIFragment::raw("<span>"),
8459                    WebUIFragment::signal("item.label", false),
8460                    WebUIFragment::raw("</span>"),
8461                ],
8462                contains_boundary: false,
8463            },
8464        );
8465        let protocol = WebUIProtocol::new(fragments);
8466        let state = test_json!({
8467            "flag": false,
8468            "items": [
8469                {"label": "A", "flag": true},
8470                {"label": "B", "flag": false},
8471                {"label": "C", "flag": true}
8472            ]
8473        });
8474        let mut writer = TestWriter::new();
8475        handle(
8476            &protocol,
8477            &state,
8478            &RenderOptions::new("index.html", "/"),
8479            &mut writer,
8480        )
8481        .unwrap();
8482        assert_eq!(
8483            writer.get_content(),
8484            "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
8485        );
8486    }
8487
8488    #[test]
8489    fn test_for_if_global_flag_no_effect() {
8490        let mut fragments = HashMap::new();
8491        fragments.insert(
8492            "index.html".to_string(),
8493            FragmentList {
8494                fragments: vec![
8495                    WebUIFragment::raw("<div>"),
8496                    WebUIFragment::for_loop("item", "items", "template1"),
8497                    WebUIFragment::raw("</div>"),
8498                ],
8499                contains_boundary: false,
8500            },
8501        );
8502        fragments.insert(
8503            "template1".to_string(),
8504            FragmentList {
8505                fragments: vec![
8506                    WebUIFragment::raw("<div>"),
8507                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8508                    WebUIFragment::raw("</div>"),
8509                ],
8510                contains_boundary: false,
8511            },
8512        );
8513        fragments.insert(
8514            "ifBlock".to_string(),
8515            FragmentList {
8516                fragments: vec![
8517                    WebUIFragment::raw("<span>"),
8518                    WebUIFragment::signal("item.label", false),
8519                    WebUIFragment::raw("</span>"),
8520                ],
8521                contains_boundary: false,
8522            },
8523        );
8524        let protocol = WebUIProtocol::new(fragments);
8525        let state = test_json!({
8526            "item": {"flag": true},
8527            "items": [
8528                {"label": "A", "flag": false},
8529                {"label": "B", "flag": true},
8530                {"label": "C", "flag": false}
8531            ]
8532        });
8533        let mut writer = TestWriter::new();
8534        handle(
8535            &protocol,
8536            &state,
8537            &RenderOptions::new("index.html", "/"),
8538            &mut writer,
8539        )
8540        .unwrap();
8541        assert_eq!(
8542            writer.get_content(),
8543            "<div><div></div><div><span>B</span></div><div></div></div>"
8544        );
8545    }
8546
8547    // ── Recursive template test ──────────────────────────────────────
8548
8549    #[test]
8550    fn test_recursive_template_refs() {
8551        let mut fragments = HashMap::new();
8552        fragments.insert(
8553            "index.html".to_string(),
8554            FragmentList {
8555                fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
8556                contains_boundary: false,
8557            },
8558        );
8559        fragments.insert(
8560            "static".to_string(),
8561            FragmentList {
8562                fragments: vec![
8563                    WebUIFragment::raw("<div expanded=\""),
8564                    WebUIFragment::signal("item.expanded", false),
8565                    WebUIFragment::raw("\" class=\""),
8566                    WebUIFragment::signal("testScenario", false),
8567                    WebUIFragment::raw("\"><span>"),
8568                    WebUIFragment::signal("item.name", false),
8569                    WebUIFragment::raw("</span>"),
8570                    WebUIFragment::for_loop("item", "item.children", "static"),
8571                    WebUIFragment::raw("</div>"),
8572                ],
8573                contains_boundary: false,
8574            },
8575        );
8576        let protocol = WebUIProtocol::new(fragments);
8577        let state = test_json!({
8578            "testScenario": "RecursiveTemplatesWithGlobalState",
8579            "items": [
8580                {"name": "A", "expanded": "false", "children": []},
8581                {"name": "B", "expanded": "true", "children": [
8582                    {"name": "C", "expanded": "false"},
8583                    {"name": "D", "expanded": "false"}
8584                ]},
8585                {"name": "E", "expanded": "false"}
8586            ]
8587        });
8588        let mut writer = TestWriter::new();
8589        handle(
8590            &protocol,
8591            &state,
8592            &RenderOptions::new("index.html", "/"),
8593            &mut writer,
8594        )
8595        .unwrap();
8596        assert_eq!(
8597            writer.get_content(),
8598            "<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>"
8599        );
8600    }
8601
8602    // ── Advanced state management tests ──────────────────────────────
8603
8604    #[test]
8605    fn test_component_in_for_no_local_access() {
8606        let mut fragments = HashMap::new();
8607        fragments.insert(
8608            "index.html".to_string(),
8609            FragmentList {
8610                fragments: vec![
8611                    WebUIFragment::raw("<div>"),
8612                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8613                    WebUIFragment::raw("</div>"),
8614                ],
8615                contains_boundary: false,
8616            },
8617        );
8618        fragments.insert(
8619            "templateComponent".to_string(),
8620            FragmentList {
8621                fragments: vec![
8622                    WebUIFragment::raw("<component-tag>"),
8623                    WebUIFragment::component("my-component"),
8624                    WebUIFragment::raw("</component-tag>"),
8625                ],
8626                contains_boundary: false,
8627            },
8628        );
8629        fragments.insert(
8630            "my-component".to_string(),
8631            FragmentList {
8632                fragments: vec![
8633                    WebUIFragment::raw("<span>"),
8634                    WebUIFragment::signal("name", false),
8635                    WebUIFragment::raw("</span>"),
8636                ],
8637                contains_boundary: false,
8638            },
8639        );
8640        let protocol = WebUIProtocol::new(fragments);
8641        let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
8642        let mut writer = TestWriter::new();
8643        handle(
8644            &protocol,
8645            &state,
8646            &RenderOptions::new("index.html", "/"),
8647            &mut writer,
8648        )
8649        .unwrap();
8650        assert_eq!(
8651            writer.get_content(),
8652            "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
8653        );
8654    }
8655
8656    #[test]
8657    fn test_nested_for_hierarchical_state() {
8658        let mut fragments = HashMap::new();
8659        fragments.insert(
8660            "index.html".to_string(),
8661            FragmentList {
8662                fragments: vec![
8663                    WebUIFragment::raw("<div>"),
8664                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8665                    WebUIFragment::raw("</div>"),
8666                ],
8667                contains_boundary: false,
8668            },
8669        );
8670        fragments.insert(
8671            "outerTemplate".to_string(),
8672            FragmentList {
8673                fragments: vec![
8674                    WebUIFragment::raw("<section>"),
8675                    WebUIFragment::signal("globalPrefix", false),
8676                    WebUIFragment::signal("outerItem.outerLabel", false),
8677                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8678                    WebUIFragment::raw("</section>"),
8679                ],
8680                contains_boundary: false,
8681            },
8682        );
8683        fragments.insert(
8684            "innerTemplate".to_string(),
8685            FragmentList {
8686                fragments: vec![
8687                    WebUIFragment::raw("<p>"),
8688                    WebUIFragment::signal("globalPrefix", false),
8689                    WebUIFragment::signal("outerItem.outerLabel", false),
8690                    WebUIFragment::raw(": "),
8691                    WebUIFragment::signal("innerItem.innerLabel", false),
8692                    WebUIFragment::raw("</p>"),
8693                ],
8694                contains_boundary: false,
8695            },
8696        );
8697        let protocol = WebUIProtocol::new(fragments);
8698        let state = test_json!({
8699            "globalPrefix": "Prefix: ",
8700            "outerItems": [
8701                {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8702                {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
8703            ]
8704        });
8705        let mut writer = TestWriter::new();
8706        handle(
8707            &protocol,
8708            &state,
8709            &RenderOptions::new("index.html", "/"),
8710            &mut writer,
8711        )
8712        .unwrap();
8713        assert_eq!(
8714            writer.get_content(),
8715            "<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>"
8716        );
8717    }
8718
8719    #[test]
8720    fn test_component_in_for_global_only() {
8721        let mut fragments = HashMap::new();
8722        fragments.insert(
8723            "index.html".to_string(),
8724            FragmentList {
8725                fragments: vec![
8726                    WebUIFragment::raw("<div>"),
8727                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8728                    WebUIFragment::raw("</div>"),
8729                ],
8730                contains_boundary: false,
8731            },
8732        );
8733        fragments.insert(
8734            "templateComponent".to_string(),
8735            FragmentList {
8736                fragments: vec![
8737                    WebUIFragment::raw("<component-tag>"),
8738                    WebUIFragment::component("my-component"),
8739                    WebUIFragment::raw("</component-tag>"),
8740                ],
8741                contains_boundary: false,
8742            },
8743        );
8744        fragments.insert(
8745            "my-component".to_string(),
8746            FragmentList {
8747                fragments: vec![
8748                    WebUIFragment::raw("<span>"),
8749                    WebUIFragment::signal("name", false),
8750                    WebUIFragment::raw("-"),
8751                    WebUIFragment::signal("globalSuffix", false),
8752                    WebUIFragment::raw("</span>"),
8753                ],
8754                contains_boundary: false,
8755            },
8756        );
8757        let protocol = WebUIProtocol::new(fragments);
8758        let state =
8759            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8760        let mut writer = TestWriter::new();
8761        handle(
8762            &protocol,
8763            &state,
8764            &RenderOptions::new("index.html", "/"),
8765            &mut writer,
8766        )
8767        .unwrap();
8768        assert_eq!(
8769            writer.get_content(),
8770            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8771        );
8772    }
8773
8774    #[test]
8775    fn test_component_no_item_moniker() {
8776        let mut fragments = HashMap::new();
8777        fragments.insert(
8778            "index.html".to_string(),
8779            FragmentList {
8780                fragments: vec![
8781                    WebUIFragment::raw("<div>"),
8782                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8783                    WebUIFragment::raw("</div>"),
8784                ],
8785                contains_boundary: false,
8786            },
8787        );
8788        fragments.insert(
8789            "templateComponent".to_string(),
8790            FragmentList {
8791                fragments: vec![
8792                    WebUIFragment::raw("<component-tag>"),
8793                    WebUIFragment::component("my-component"),
8794                    WebUIFragment::raw("</component-tag>"),
8795                ],
8796                contains_boundary: false,
8797            },
8798        );
8799        fragments.insert(
8800            "my-component".to_string(),
8801            FragmentList {
8802                fragments: vec![
8803                    WebUIFragment::raw("<span>"),
8804                    WebUIFragment::signal("item.name", false),
8805                    WebUIFragment::raw("-"),
8806                    WebUIFragment::signal("globalSuffix", false),
8807                    WebUIFragment::raw("</span>"),
8808                ],
8809                contains_boundary: false,
8810            },
8811        );
8812        let protocol = WebUIProtocol::new(fragments);
8813        let state =
8814            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8815        let mut writer = TestWriter::new();
8816        handle(
8817            &protocol,
8818            &state,
8819            &RenderOptions::new("index.html", "/"),
8820            &mut writer,
8821        )
8822        .unwrap();
8823        assert_eq!(
8824            writer.get_content(),
8825            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8826        );
8827    }
8828
8829    #[test]
8830    fn test_for_nonqualified_uses_global() {
8831        let mut fragments = HashMap::new();
8832        fragments.insert(
8833            "index.html".to_string(),
8834            FragmentList {
8835                fragments: vec![
8836                    WebUIFragment::raw("<div>"),
8837                    WebUIFragment::for_loop("item", "items", "template1"),
8838                    WebUIFragment::raw("</div>"),
8839                ],
8840                contains_boundary: false,
8841            },
8842        );
8843        fragments.insert(
8844            "template1".to_string(),
8845            FragmentList {
8846                fragments: vec![
8847                    WebUIFragment::raw("<span>"),
8848                    WebUIFragment::signal("name", false),
8849                    WebUIFragment::raw("</span>"),
8850                ],
8851                contains_boundary: false,
8852            },
8853        );
8854        let protocol = WebUIProtocol::new(fragments);
8855        let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
8856        let mut writer = TestWriter::new();
8857        handle(
8858            &protocol,
8859            &state,
8860            &RenderOptions::new("index.html", "/"),
8861            &mut writer,
8862        )
8863        .unwrap();
8864        assert_eq!(
8865            writer.get_content(),
8866            "<div><span>GlobalName</span><span>GlobalName</span></div>"
8867        );
8868    }
8869
8870    #[test]
8871    fn test_nested_for_if_interleaved() {
8872        let mut fragments = HashMap::new();
8873        fragments.insert(
8874            "index.html".to_string(),
8875            FragmentList {
8876                fragments: vec![
8877                    WebUIFragment::raw("<div>"),
8878                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8879                    WebUIFragment::raw("</div>"),
8880                ],
8881                contains_boundary: false,
8882            },
8883        );
8884        fragments.insert(
8885            "outerTemplate".to_string(),
8886            FragmentList {
8887                fragments: vec![
8888                    WebUIFragment::raw("<section>"),
8889                    WebUIFragment::signal("globalPrefix", false),
8890                    WebUIFragment::signal("outerItem.outerLabel", false),
8891                    WebUIFragment::if_cond(
8892                        ConditionExpr::identifier("outerItem.include"),
8893                        "ifTemplate",
8894                    ),
8895                    WebUIFragment::raw("</section>"),
8896                ],
8897                contains_boundary: false,
8898            },
8899        );
8900        fragments.insert(
8901            "ifTemplate".to_string(),
8902            FragmentList {
8903                fragments: vec![
8904                    WebUIFragment::raw("<div>"),
8905                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8906                    WebUIFragment::raw("</div>"),
8907                ],
8908                contains_boundary: false,
8909            },
8910        );
8911        fragments.insert(
8912            "innerTemplate".to_string(),
8913            FragmentList {
8914                fragments: vec![
8915                    WebUIFragment::raw("<p>"),
8916                    WebUIFragment::signal("globalSuffix", false),
8917                    WebUIFragment::raw(": "),
8918                    WebUIFragment::signal("innerItem.innerLabel", false),
8919                    WebUIFragment::raw("</p>"),
8920                ],
8921                contains_boundary: false,
8922            },
8923        );
8924        let protocol = WebUIProtocol::new(fragments);
8925        let state = test_json!({
8926            "globalPrefix": "Prefix: ",
8927            "globalSuffix": "Suffix",
8928            "outerItems": [
8929                {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8930                {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
8931                {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
8932            ]
8933        });
8934        let mut writer = TestWriter::new();
8935        handle(
8936            &protocol,
8937            &state,
8938            &RenderOptions::new("index.html", "/"),
8939            &mut writer,
8940        )
8941        .unwrap();
8942        assert_eq!(
8943            writer.get_content(),
8944            "<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>"
8945        );
8946    }
8947
8948    #[test]
8949    fn test_nested_for_if_outer_state() {
8950        let mut fragments = HashMap::new();
8951        fragments.insert(
8952            "index.html".to_string(),
8953            FragmentList {
8954                fragments: vec![
8955                    WebUIFragment::raw("<div>"),
8956                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8957                    WebUIFragment::raw("</div>"),
8958                ],
8959                contains_boundary: false,
8960            },
8961        );
8962        fragments.insert(
8963            "outerTemplate".to_string(),
8964            FragmentList {
8965                fragments: vec![
8966                    WebUIFragment::raw("<section>"),
8967                    WebUIFragment::signal("globalPrefix", false),
8968                    WebUIFragment::signal("outerItem.label", false),
8969                    WebUIFragment::for_loop(
8970                        "middleItem",
8971                        "outerItem.middleItems",
8972                        "middleTemplate",
8973                    ),
8974                    WebUIFragment::raw("</section>"),
8975                ],
8976                contains_boundary: false,
8977            },
8978        );
8979        fragments.insert(
8980            "middleTemplate".to_string(),
8981            FragmentList {
8982                fragments: vec![
8983                    WebUIFragment::raw("<div>"),
8984                    WebUIFragment::if_cond(
8985                        ConditionExpr::identifier("outerItem.active"),
8986                        "ifTemplate",
8987                    ),
8988                    WebUIFragment::raw("</div>"),
8989                ],
8990                contains_boundary: false,
8991            },
8992        );
8993        fragments.insert(
8994            "ifTemplate".to_string(),
8995            FragmentList {
8996                fragments: vec![
8997                    WebUIFragment::raw("<p>"),
8998                    WebUIFragment::signal("middleItem.value", false),
8999                    WebUIFragment::raw("</p>"),
9000                ],
9001                contains_boundary: false,
9002            },
9003        );
9004        let protocol = WebUIProtocol::new(fragments);
9005        let state = test_json!({
9006            "globalPrefix": "GP-",
9007            "outerItems": [
9008                {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
9009                {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
9010                {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
9011            ]
9012        });
9013        let mut writer = TestWriter::new();
9014        handle(
9015            &protocol,
9016            &state,
9017            &RenderOptions::new("index.html", "/"),
9018            &mut writer,
9019        )
9020        .unwrap();
9021        assert_eq!(
9022            writer.get_content(),
9023            "<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>"
9024        );
9025    }
9026
9027    #[test]
9028    fn test_nested_for_if_inner_state() {
9029        let mut fragments = HashMap::new();
9030        fragments.insert(
9031            "index.html".to_string(),
9032            FragmentList {
9033                fragments: vec![
9034                    WebUIFragment::raw("<div>"),
9035                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9036                    WebUIFragment::raw("</div>"),
9037                ],
9038                contains_boundary: false,
9039            },
9040        );
9041        fragments.insert(
9042            "outerTemplate".to_string(),
9043            FragmentList {
9044                fragments: vec![
9045                    WebUIFragment::raw("<section>"),
9046                    WebUIFragment::signal("outerItem.label", false),
9047                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
9048                    WebUIFragment::raw("</section>"),
9049                ],
9050                contains_boundary: false,
9051            },
9052        );
9053        fragments.insert(
9054            "innerTemplate".to_string(),
9055            FragmentList {
9056                fragments: vec![
9057                    WebUIFragment::raw("<article>"),
9058                    WebUIFragment::if_cond(
9059                        ConditionExpr::identifier("innerItem.show"),
9060                        "ifTemplate",
9061                    ),
9062                    WebUIFragment::raw("</article>"),
9063                ],
9064                contains_boundary: false,
9065            },
9066        );
9067        fragments.insert(
9068            "ifTemplate".to_string(),
9069            FragmentList {
9070                fragments: vec![
9071                    WebUIFragment::raw("<p>"),
9072                    WebUIFragment::signal("innerItem.detail", false),
9073                    WebUIFragment::raw("</p>"),
9074                ],
9075                contains_boundary: false,
9076            },
9077        );
9078        let protocol = WebUIProtocol::new(fragments);
9079        let state = test_json!({
9080            "outerItems": [
9081                {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
9082                {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
9083            ]
9084        });
9085        let mut writer = TestWriter::new();
9086        handle(
9087            &protocol,
9088            &state,
9089            &RenderOptions::new("index.html", "/"),
9090            &mut writer,
9091        )
9092        .unwrap();
9093        assert_eq!(
9094            writer.get_content(),
9095            "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
9096        );
9097    }
9098
9099    #[test]
9100    fn test_for_merge_local_global_monikers() {
9101        let mut fragments = HashMap::new();
9102        fragments.insert(
9103            "index.html".to_string(),
9104            FragmentList {
9105                fragments: vec![
9106                    WebUIFragment::raw("<div>"),
9107                    WebUIFragment::for_loop("item", "items", "template1"),
9108                    WebUIFragment::raw("</div>"),
9109                ],
9110                contains_boundary: false,
9111            },
9112        );
9113        fragments.insert(
9114            "template1".to_string(),
9115            FragmentList {
9116                fragments: vec![
9117                    WebUIFragment::raw("<span>"),
9118                    WebUIFragment::signal("item.name", false),
9119                    WebUIFragment::raw("-"),
9120                    WebUIFragment::signal("item.globalValue", false),
9121                    WebUIFragment::raw("-"),
9122                    WebUIFragment::signal("item.localOnly", false),
9123                    WebUIFragment::raw("-"),
9124                    WebUIFragment::signal("item.otherVal", false),
9125                    WebUIFragment::raw("</span>"),
9126                ],
9127                contains_boundary: false,
9128            },
9129        );
9130        let protocol = WebUIProtocol::new(fragments);
9131        let state = test_json!({
9132            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
9133            "items": [
9134                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
9135                {"name": "Local2", "localOnly": "Only2"}
9136            ]
9137        });
9138        let mut writer = TestWriter::new();
9139        handle(
9140            &protocol,
9141            &state,
9142            &RenderOptions::new("index.html", "/"),
9143            &mut writer,
9144        )
9145        .unwrap();
9146        assert_eq!(
9147            writer.get_content(),
9148            "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
9149        );
9150    }
9151
9152    #[test]
9153    fn test_component_in_for_global_moniker_shadow() {
9154        let mut fragments = HashMap::new();
9155        fragments.insert(
9156            "index.html".to_string(),
9157            FragmentList {
9158                fragments: vec![
9159                    WebUIFragment::raw("<div>"),
9160                    WebUIFragment::for_loop("item", "items", "templateComponent"),
9161                    WebUIFragment::raw("</div>"),
9162                ],
9163                contains_boundary: false,
9164            },
9165        );
9166        fragments.insert(
9167            "templateComponent".to_string(),
9168            FragmentList {
9169                fragments: vec![
9170                    WebUIFragment::raw("<component-tag>"),
9171                    WebUIFragment::component("my-component"),
9172                    WebUIFragment::raw("</component-tag>"),
9173                ],
9174                contains_boundary: false,
9175            },
9176        );
9177        fragments.insert(
9178            "my-component".to_string(),
9179            FragmentList {
9180                fragments: vec![
9181                    WebUIFragment::raw("<span>"),
9182                    WebUIFragment::signal("name", false),
9183                    WebUIFragment::raw("-"),
9184                    WebUIFragment::signal("item.globalValue", false),
9185                    WebUIFragment::raw("-"),
9186                    WebUIFragment::signal("localOnly", false),
9187                    WebUIFragment::raw("-"),
9188                    WebUIFragment::signal("item.otherVal", false),
9189                    WebUIFragment::raw("</span>"),
9190                ],
9191                contains_boundary: false,
9192            },
9193        );
9194        let protocol = WebUIProtocol::new(fragments);
9195        let state = test_json!({
9196            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
9197            "items": [
9198                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
9199                {"name": "Local2", "localOnly": "Only2"}
9200            ]
9201        });
9202        let mut writer = TestWriter::new();
9203        handle(
9204            &protocol,
9205            &state,
9206            &RenderOptions::new("index.html", "/"),
9207            &mut writer,
9208        )
9209        .unwrap();
9210        assert_eq!(
9211            writer.get_content(),
9212            "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
9213        );
9214    }
9215
9216    #[test]
9217    fn test_if_in_nested_for_local_flag() {
9218        let mut fragments = HashMap::new();
9219        fragments.insert(
9220            "index.html".to_string(),
9221            FragmentList {
9222                fragments: vec![
9223                    WebUIFragment::raw("<div>"),
9224                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
9225                    WebUIFragment::raw("</div>"),
9226                ],
9227                contains_boundary: false,
9228            },
9229        );
9230        fragments.insert(
9231            "outerTemplate".to_string(),
9232            FragmentList {
9233                fragments: vec![
9234                    WebUIFragment::raw("<section>"),
9235                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
9236                    WebUIFragment::raw("</section>"),
9237                ],
9238                contains_boundary: false,
9239            },
9240        );
9241        fragments.insert(
9242            "innerTemplate".to_string(),
9243            FragmentList {
9244                fragments: vec![WebUIFragment::if_cond(
9245                    ConditionExpr::identifier("inner_item.flag"),
9246                    "ifInner",
9247                )],
9248                contains_boundary: false,
9249            },
9250        );
9251        fragments.insert(
9252            "ifInner".to_string(),
9253            FragmentList {
9254                fragments: vec![
9255                    WebUIFragment::raw("<p>"),
9256                    WebUIFragment::signal("inner_item.value", false),
9257                    WebUIFragment::raw("</p>"),
9258                ],
9259                contains_boundary: false,
9260            },
9261        );
9262        let protocol = WebUIProtocol::new(fragments);
9263        let state = test_json!({
9264            "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
9265            "inner_item": {"flag": false}
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><section><p>X</p></section></div>"
9278        );
9279    }
9280
9281    #[test]
9282    fn test_if_in_nested_for_global_fallback() {
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("outer", "list.outer_items", "outerTemplate"),
9290                    WebUIFragment::raw("</div>"),
9291                ],
9292                contains_boundary: false,
9293            },
9294        );
9295        fragments.insert(
9296            "outerTemplate".to_string(),
9297            FragmentList {
9298                fragments: vec![
9299                    WebUIFragment::raw("<section>"),
9300                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
9301                    WebUIFragment::raw("</section>"),
9302                ],
9303                contains_boundary: false,
9304            },
9305        );
9306        fragments.insert(
9307            "innerTemplate".to_string(),
9308            FragmentList {
9309                fragments: vec![WebUIFragment::if_cond(
9310                    ConditionExpr::identifier("inner_item.flag"),
9311                    "ifInner",
9312                )],
9313                contains_boundary: false,
9314            },
9315        );
9316        fragments.insert(
9317            "ifInner".to_string(),
9318            FragmentList {
9319                fragments: vec![
9320                    WebUIFragment::raw("<p>"),
9321                    WebUIFragment::signal("inner_item.value", false),
9322                    WebUIFragment::raw("</p>"),
9323                ],
9324                contains_boundary: false,
9325            },
9326        );
9327        let protocol = WebUIProtocol::new(fragments);
9328        let state = test_json!({
9329            "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
9330            "inner_item": {"flag": true}
9331        });
9332        let mut writer = TestWriter::new();
9333        handle(
9334            &protocol,
9335            &state,
9336            &RenderOptions::new("index.html", "/"),
9337            &mut writer,
9338        )
9339        .unwrap();
9340        assert_eq!(
9341            writer.get_content(),
9342            "<div><section><p>X</p><p>Y</p></section></div>"
9343        );
9344    }
9345
9346    #[test]
9347    fn test_if_mixed_for_monikers() {
9348        let mut fragments = HashMap::new();
9349        fragments.insert(
9350            "index.html".to_string(),
9351            FragmentList {
9352                fragments: vec![
9353                    WebUIFragment::raw("<div>"),
9354                    WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
9355                    WebUIFragment::raw("</div>"),
9356                ],
9357                contains_boundary: false,
9358            },
9359        );
9360        fragments.insert(
9361            "outerTemplate".to_string(),
9362            FragmentList {
9363                fragments: vec![
9364                    WebUIFragment::raw("<section>"),
9365                    WebUIFragment::signal("outer.outerLabel", false),
9366                    WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
9367                    WebUIFragment::raw("</section>"),
9368                ],
9369                contains_boundary: false,
9370            },
9371        );
9372        fragments.insert(
9373            "innerTemplate".to_string(),
9374            FragmentList {
9375                fragments: vec![WebUIFragment::if_cond(
9376                    ConditionExpr::compound(
9377                        ConditionExpr::identifier("outer.active"),
9378                        LogicalOperator::And,
9379                        ConditionExpr::predicate(
9380                            "inner.value",
9381                            ComparisonOperator::GreaterThan,
9382                            "globalLimit",
9383                        ),
9384                    ),
9385                    "ifInner",
9386                )],
9387                contains_boundary: false,
9388            },
9389        );
9390        fragments.insert(
9391            "ifInner".to_string(),
9392            FragmentList {
9393                fragments: vec![
9394                    WebUIFragment::raw("<p>"),
9395                    WebUIFragment::signal("inner.value", false),
9396                    WebUIFragment::raw("</p>"),
9397                ],
9398                contains_boundary: false,
9399            },
9400        );
9401        let protocol = WebUIProtocol::new(fragments);
9402        let state = test_json!({
9403            "globalLimit": 10,
9404            "list": {"outerItems": [
9405                {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
9406                {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
9407                {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
9408            ]}
9409        });
9410        let mut writer = TestWriter::new();
9411        handle(
9412            &protocol,
9413            &state,
9414            &RenderOptions::new("index.html", "/"),
9415            &mut writer,
9416        )
9417        .unwrap();
9418        assert_eq!(
9419            writer.get_content(),
9420            "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
9421        );
9422    }
9423
9424    // ── Route-aware rendering tests ─────────────────────────────────────
9425
9426    fn make_route_protocol() -> WebUIProtocol {
9427        use webui_protocol::WebUiFragmentRoute;
9428
9429        let mut fragments = HashMap::new();
9430
9431        // Entry page with two routes
9432        fragments.insert(
9433            "index.html".to_string(),
9434            FragmentList {
9435                fragments: vec![
9436                    WebUIFragment::raw("<h1>Shell</h1>"),
9437                    WebUIFragment::route_from(WebUiFragmentRoute {
9438                        path: "/".into(),
9439                        fragment_id: "dash-page".into(),
9440                        exact: true,
9441                        keep_alive: false,
9442                        ..Default::default()
9443                    }),
9444                    WebUIFragment::route_from(WebUiFragmentRoute {
9445                        path: "/contacts/:id".into(),
9446                        fragment_id: "detail-page".into(),
9447                        exact: true,
9448                        keep_alive: true,
9449                        ..Default::default()
9450                    }),
9451                ],
9452
9453                contains_boundary: false,
9454            },
9455        );
9456
9457        // Dashboard page component
9458        fragments.insert(
9459            "dash-page".to_string(),
9460            FragmentList {
9461                fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
9462                contains_boundary: false,
9463            },
9464        );
9465
9466        // Detail page component
9467        fragments.insert(
9468            "detail-page".to_string(),
9469            FragmentList {
9470                fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
9471                contains_boundary: false,
9472            },
9473        );
9474
9475        WebUIProtocol::new(fragments)
9476    }
9477
9478    fn make_nested_route_protocol() -> WebUIProtocol {
9479        use webui_protocol::WebUiFragmentRoute;
9480
9481        let mut fragments = HashMap::new();
9482
9483        fragments.insert(
9484            "index.html".to_string(),
9485            FragmentList {
9486                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
9487                    path: "/".into(),
9488                    fragment_id: "app-shell".into(),
9489                    exact: false,
9490                    children: vec![WebUiFragmentRoute {
9491                        path: "sections/:id".into(),
9492                        fragment_id: "section-comp".into(),
9493                        exact: false,
9494                        children: vec![WebUiFragmentRoute {
9495                            path: "topics/:topicId".into(),
9496                            fragment_id: "topic-comp".into(),
9497                            exact: true,
9498                            children: vec![],
9499                            keep_alive: false,
9500                            ..Default::default()
9501                        }],
9502                        keep_alive: false,
9503                        ..Default::default()
9504                    }],
9505                    keep_alive: false,
9506                    ..Default::default()
9507                })],
9508
9509                contains_boundary: false,
9510            },
9511        );
9512
9513        fragments.insert(
9514            "app-shell".to_string(),
9515            FragmentList {
9516                fragments: vec![
9517                    WebUIFragment::raw("<h1>Shell</h1>"),
9518                    WebUIFragment::outlet(),
9519                ],
9520                contains_boundary: false,
9521            },
9522        );
9523
9524        fragments.insert(
9525            "section-comp".to_string(),
9526            FragmentList {
9527                fragments: vec![
9528                    WebUIFragment::raw("<h2>Section</h2>"),
9529                    WebUIFragment::outlet(),
9530                ],
9531                contains_boundary: false,
9532            },
9533        );
9534
9535        fragments.insert(
9536            "topic-comp".to_string(),
9537            FragmentList {
9538                fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
9539                contains_boundary: false,
9540            },
9541        );
9542
9543        WebUIProtocol::new(fragments)
9544    }
9545
9546    #[test]
9547    fn test_route_renders_shell_always() {
9548        let protocol = make_route_protocol();
9549        let state = test_json!({});
9550        let mut writer = TestWriter::new();
9551        handle(
9552            &protocol,
9553            &state,
9554            &RenderOptions::new("index.html", "/"),
9555            &mut writer,
9556        )
9557        .unwrap();
9558        let html = writer.get_content();
9559
9560        // Shell content always renders regardless of route matching
9561        assert!(html.contains("<h1>Shell</h1>"), "shell should render");
9562        // Dashboard matches "/" so it should be active
9563        assert!(html.contains(" active>"), "matched route should be active");
9564        // Detail should be hidden and empty
9565        assert!(
9566            html.contains("style=\"display:none\""),
9567            "non-matched routes should be hidden"
9568        );
9569    }
9570
9571    #[test]
9572    fn test_route_matched_renders_visible() {
9573        let protocol = make_route_protocol();
9574        let state = test_json!({});
9575        let mut writer = TestWriter::new();
9576        handle(
9577            &protocol,
9578            &state,
9579            &RenderOptions::new("index.html", "/"),
9580            &mut writer,
9581        )
9582        .unwrap();
9583        let html = writer.get_content();
9584
9585        // Dashboard route should be visible (active, no display:none)
9586        assert!(
9587            html.contains("<webui-route path=\"/\""),
9588            "dashboard route should exist"
9589        );
9590        assert!(
9591            html.contains("active>") && html.contains("<dash-page"),
9592            "matched route should be active with component tag: {html}"
9593        );
9594        assert!(
9595            html.contains("<p>Dashboard</p>"),
9596            "matched route should have content"
9597        );
9598    }
9599
9600    #[test]
9601    fn test_route_non_matched_renders_hidden_empty() {
9602        let protocol = make_route_protocol();
9603        let state = test_json!({});
9604        let mut writer = TestWriter::new();
9605        handle(
9606            &protocol,
9607            &state,
9608            &RenderOptions::new("index.html", "/"),
9609            &mut writer,
9610        )
9611        .unwrap();
9612        let html = writer.get_content();
9613
9614        // Detail route should be hidden and empty (no content rendered)
9615        assert!(
9616            html.contains("<webui-route path=\"/contacts/:id\""),
9617            "detail route element should exist"
9618        );
9619        // The non-matched route should have display:none and no inner content
9620        let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
9621        let after_detail = &html[detail_start..];
9622        assert!(
9623            after_detail.contains("style=\"display:none\">")
9624                && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
9625            "non-matched route should be hidden: {after_detail}"
9626        );
9627        // Should NOT contain the component's rendered content
9628        let detail_end = after_detail.find("</webui-route>").expect("closing tag");
9629        let detail_body = &after_detail[..detail_end];
9630        assert!(
9631            !detail_body.contains("<detail-page"),
9632            "non-matched route should not render component content: {detail_body}"
9633        );
9634    }
9635
9636    #[test]
9637    fn test_route_parameterized_match() {
9638        let protocol = make_route_protocol();
9639        let state = test_json!({});
9640        let mut writer = TestWriter::new();
9641        handle(
9642            &protocol,
9643            &state,
9644            &RenderOptions::new("index.html", "/contacts/42"),
9645            &mut writer,
9646        )
9647        .unwrap();
9648        let html = writer.get_content();
9649
9650        // Detail route matches /contacts/42
9651        assert!(
9652            html.contains("active>") && html.contains("<detail-page"),
9653            "detail route should be active: {html}"
9654        );
9655        assert!(html.contains("<p>Detail</p>"), "detail should have content");
9656        // Dashboard should be hidden + empty
9657        let dash_start = html
9658            .find("component=\"dash-page\"")
9659            .expect("dashboard route");
9660        let after_dash = &html[dash_start..];
9661        assert!(
9662            after_dash.contains("style=\"display:none\">"),
9663            "dashboard should be hidden when detail matches: {after_dash}"
9664        );
9665        let dash_end = after_dash.find("</webui-route>").expect("closing tag");
9666        let dash_body = &after_dash[..dash_end];
9667        assert!(
9668            !dash_body.contains("<dash-page"),
9669            "dashboard should not render component content: {dash_body}"
9670        );
9671    }
9672
9673    #[test]
9674    fn test_route_no_match_all_hidden_empty() {
9675        let protocol = make_route_protocol();
9676        let state = test_json!({});
9677        let mut writer = TestWriter::new();
9678        handle(
9679            &protocol,
9680            &state,
9681            &RenderOptions::new("index.html", "/nonexistent"),
9682            &mut writer,
9683        )
9684        .unwrap();
9685        let html = writer.get_content();
9686
9687        // Shell content should still render
9688        assert!(html.contains("<h1>Shell</h1>"));
9689        // All routes should be hidden + empty (nothing matched)
9690        assert!(
9691            !html.contains("<p>Dashboard</p>"),
9692            "no route content when nothing matches"
9693        );
9694        assert!(
9695            !html.contains("<p>Detail</p>"),
9696            "no route content when nothing matches"
9697        );
9698    }
9699
9700    #[test]
9701    fn test_route_component_attr_emitted() {
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        // component attribute should be emitted on webui-route
9714        assert!(
9715            html.contains("component=\"dash-page\""),
9716            "component attr should be on webui-route: {html}"
9717        );
9718        assert!(
9719            html.contains("component=\"detail-page\""),
9720            "component attr should be on webui-route: {html}"
9721        );
9722        assert!(
9723            html.contains(r#"component="detail-page" exact keep-alive style="display:none">"#),
9724            "keep-alive should be emitted on an unmatched destination placeholder: {html}"
9725        );
9726    }
9727
9728    #[test]
9729    fn test_no_plugin_no_state_attributes() {
9730        let protocol = make_route_protocol();
9731        let state = test_json!({
9732            "title": "Fish & Chips",
9733            "cartOpen": true,
9734            "items": [{"name": "A&B"}]
9735        });
9736        let mut writer = TestWriter::new();
9737
9738        handle(
9739            &protocol,
9740            &state,
9741            &RenderOptions::new("index.html", "/"),
9742            &mut writer,
9743        )
9744        .unwrap();
9745
9746        let html = writer.get_content();
9747        // Without a plugin, no state attributes at all
9748        assert!(
9749            !html.contains("data-state"),
9750            "no data-state without plugin: {html}"
9751        );
9752        assert!(
9753            !html.contains(r#"title="Fish"#),
9754            "no scalar attrs without plugin: {html}"
9755        );
9756    }
9757
9758    #[test]
9759    fn test_nested_routes_render_webui_route_as_light_dom() {
9760        let protocol = make_nested_route_protocol();
9761        let state = test_json!({"title": "Test"});
9762        let handler = WebUIHandler::new();
9763        let mut writer = TestWriter::new();
9764
9765        handler
9766            .handle(
9767                &protocol,
9768                &state,
9769                &RenderOptions::new("index.html", "/sections/frontend"),
9770                &mut writer,
9771            )
9772            .expect("render failed");
9773
9774        let html = writer.get_content();
9775
9776        assert!(
9777            html.contains("component=\"app-shell\"") && html.contains("active>"),
9778            "root route should be active: {html}"
9779        );
9780        // webui-route should NOT have shadow DOM — it's a light DOM structural element
9781        assert!(
9782            !html.contains("<template shadowrootmode"),
9783            "webui-route should be light DOM (no shadow template): {html}"
9784        );
9785    }
9786
9787    #[test]
9788    fn test_nested_routes_render_outlet_as_light_dom() {
9789        let protocol = make_nested_route_protocol();
9790        let state = test_json!({"title": "Test"});
9791        let handler = WebUIHandler::new();
9792        let mut writer = TestWriter::new();
9793
9794        handler
9795            .handle(
9796                &protocol,
9797                &state,
9798                &RenderOptions::new("index.html", "/sections/frontend"),
9799                &mut writer,
9800            )
9801            .expect("render failed");
9802
9803        let html = writer.get_content();
9804
9805        // No <webui-outlet> wrapper — routes render directly at outlet position
9806        assert!(
9807            !html.contains("<webui-outlet>"),
9808            "should not contain webui-outlet wrapper: {html}"
9809        );
9810        // Route elements should be in the output directly
9811        assert!(
9812            html.contains("<webui-route"),
9813            "should contain webui-route elements: {html}"
9814        );
9815    }
9816
9817    #[test]
9818    fn test_nested_routes_match_child_at_outlet() {
9819        let protocol = make_nested_route_protocol();
9820        let state = test_json!({"title": "Test"});
9821        let handler = WebUIHandler::new();
9822        let mut writer = TestWriter::new();
9823
9824        handler
9825            .handle(
9826                &protocol,
9827                &state,
9828                &RenderOptions::new("index.html", "/sections/frontend"),
9829                &mut writer,
9830            )
9831            .expect("render failed");
9832
9833        let html = writer.get_content();
9834
9835        assert!(
9836            html.contains("component=\"section-comp\"") && html.contains("active>"),
9837            "section route should be active: {html}"
9838        );
9839        assert!(
9840            html.contains("<h2>Section</h2>"),
9841            "section content should be present: {html}"
9842        );
9843    }
9844
9845    #[test]
9846    fn test_nested_routes_three_levels_deep() {
9847        let protocol = make_nested_route_protocol();
9848        let state = test_json!({"title": "Test"});
9849        let handler = WebUIHandler::new();
9850        let mut writer = TestWriter::new();
9851
9852        handler
9853            .handle(
9854                &protocol,
9855                &state,
9856                &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
9857                &mut writer,
9858            )
9859            .expect("render failed");
9860
9861        let html = writer.get_content();
9862
9863        assert!(
9864            html.contains("component=\"app-shell\"") && html.contains("active>"),
9865            "root active: {html}"
9866        );
9867        assert!(
9868            html.contains("component=\"section-comp\"") && html.contains("active>"),
9869            "section active: {html}"
9870        );
9871        assert!(
9872            html.contains("component=\"topic-comp\"")
9873                && html.contains("exact")
9874                && html.contains("active>"),
9875            "topic active: {html}"
9876        );
9877        assert!(
9878            html.contains("<p>Topic content</p>"),
9879            "leaf content present: {html}"
9880        );
9881    }
9882
9883    #[test]
9884    fn test_nested_routes_nonmatched_siblings_hidden() {
9885        let protocol = make_nested_route_protocol();
9886        let state = test_json!({"title": "Test"});
9887        let handler = WebUIHandler::new();
9888        let mut writer = TestWriter::new();
9889
9890        handler
9891            .handle(
9892                &protocol,
9893                &state,
9894                &RenderOptions::new("index.html", "/sections/frontend"),
9895                &mut writer,
9896            )
9897            .expect("render failed");
9898
9899        let html = writer.get_content();
9900
9901        assert!(
9902            html.contains(r#"component="topic-comp" exact style="display:none">"#),
9903            "topic should be hidden: {html}"
9904        );
9905    }
9906
9907    #[test]
9908    fn test_nested_routes_preserve_sibling_declaration_order() {
9909        let mut protocol = make_nested_route_protocol();
9910        let Some(Fragment::Route(root)) = protocol
9911            .fragments
9912            .get_mut("index.html")
9913            .and_then(|entry| entry.fragments.first_mut())
9914            .and_then(|fragment| fragment.fragment.as_mut())
9915        else {
9916            panic!("nested route fixture must have a root route");
9917        };
9918        root.children.insert(
9919            0,
9920            webui_protocol::WebUiFragmentRoute {
9921                path: String::new(),
9922                fragment_id: "topic-comp".into(),
9923                exact: true,
9924                ..Default::default()
9925            },
9926        );
9927        root.children.push(webui_protocol::WebUiFragmentRoute {
9928            path: "*rest".into(),
9929            fragment_id: "topic-comp".into(),
9930            exact: true,
9931            ..Default::default()
9932        });
9933
9934        for path in ["/", "/sections/frontend", "/missing"] {
9935            let mut writer = TestWriter::new();
9936            WebUIHandler::new()
9937                .handle(
9938                    &protocol,
9939                    &test_json!({}),
9940                    &RenderOptions::new("index.html", path),
9941                    &mut writer,
9942                )
9943                .expect("render failed");
9944            let html = writer.get_content();
9945            let first = html.find(r#"<webui-route path="""#).expect("empty route");
9946            let middle = html
9947                .find(r#"<webui-route path="sections/:id""#)
9948                .expect("section route");
9949            let last = html
9950                .find(r#"<webui-route path="*rest""#)
9951                .expect("catch-all route");
9952            assert!(first < middle && middle < last, "{path}: {html}");
9953        }
9954    }
9955
9956    #[test]
9957    fn test_nested_routes_root_only() {
9958        let protocol = make_nested_route_protocol();
9959        let state = test_json!({"title": "Test"});
9960        let handler = WebUIHandler::new();
9961        let mut writer = TestWriter::new();
9962
9963        handler
9964            .handle(
9965                &protocol,
9966                &state,
9967                &RenderOptions::new("index.html", "/"),
9968                &mut writer,
9969            )
9970            .expect("render failed");
9971
9972        let html = writer.get_content();
9973
9974        assert!(
9975            html.contains("component=\"app-shell\"") && html.contains("active>"),
9976            "root active at /: {html}"
9977        );
9978        assert!(
9979            html.contains("<h1>Shell</h1>"),
9980            "shell renders at /: {html}"
9981        );
9982        assert!(
9983            html.contains(r#"component="section-comp" style="display:none">"#),
9984            "section hidden at /: {html}"
9985        );
9986    }
9987
9988    // ── CSS Module dedup tests ───────────────────────────────────────
9989
9990    #[test]
9991    fn test_css_module_emitted_once_inline_in_component() {
9992        // CSS module definition emitted once in the component's light DOM
9993        // on first render, not in <head> and not on second instance.
9994        let template = r#"<p><slot></slot></p>"#;
9995
9996        let mut fragments = HashMap::new();
9997        fragments.insert(
9998            "index.html".to_string(),
9999            FragmentList {
10000                fragments: vec![
10001                    WebUIFragment::raw("<html><head>".to_string()),
10002                    structural_fragment("head_end"),
10003                    WebUIFragment::raw("</head><body><div>".to_string()),
10004                    WebUIFragment::component("my-card"),
10005                    WebUIFragment::raw("A".to_string()),
10006                    WebUIFragment::component("my-card"),
10007                    WebUIFragment::raw("B</div>".to_string()),
10008                    structural_fragment("body_end"),
10009                    WebUIFragment::raw("</body></html>".to_string()),
10010                ],
10011                contains_boundary: false,
10012            },
10013        );
10014        fragments.insert(
10015            "my-card".to_string(),
10016            FragmentList {
10017                fragments: vec![WebUIFragment::raw(template.to_string())],
10018                contains_boundary: false,
10019            },
10020        );
10021
10022        let mut protocol = WebUIProtocol::new(fragments);
10023        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
10024        protocol
10025            .components
10026            .entry("my-card".to_string())
10027            .or_default()
10028            .css = "p{color:red}".to_string();
10029        protocol.populate_style_closures(&["index.html"]);
10030        let state = test_json!({});
10031        let mut writer = TestWriter::new();
10032
10033        handle(
10034            &protocol,
10035            &state,
10036            &RenderOptions::new("index.html", "/"),
10037            &mut writer,
10038        )
10039        .unwrap();
10040
10041        let html = writer.get_content();
10042
10043        // CSS module importmap should appear exactly once
10044        let count = html.matches(r#"<script type="importmap""#).count();
10045        assert_eq!(
10046            count, 1,
10047            "CSS module importmap should be emitted once, got {count} in: {html}"
10048        );
10049        assert!(
10050            html.contains(r#""my-card":"data:text/css,"#),
10051            "Importmap must register my-card under a data: URI: {html}"
10052        );
10053
10054        // Template content should appear twice (once per component instance)
10055        let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
10056        assert_eq!(
10057            tmpl_count, 2,
10058            "Template should render twice, got {tmpl_count} in: {html}"
10059        );
10060
10061        // CSS module should be in <body> (inline), not in <head>
10062        let css_pos = html
10063            .find(r#"<script type="importmap""#)
10064            .expect("CSS module importmap missing");
10065        let body_pos = html.find("<body>").expect("<body> missing");
10066        assert!(
10067            css_pos > body_pos,
10068            "CSS module should be inline in component, not in <head>: {html}"
10069        );
10070    }
10071
10072    #[test]
10073    fn test_component_without_css_renders_normally() {
10074        // Components without CSS module prefix pass through unchanged
10075        let mut fragments = HashMap::new();
10076        fragments.insert(
10077            "index.html".to_string(),
10078            FragmentList {
10079                fragments: vec![WebUIFragment::component("my-card")],
10080                contains_boundary: false,
10081            },
10082        );
10083        fragments.insert(
10084            "my-card".to_string(),
10085            FragmentList {
10086                fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
10087                contains_boundary: false,
10088            },
10089        );
10090
10091        let protocol = WebUIProtocol::new(fragments);
10092        let state = test_json!({});
10093        let mut writer = TestWriter::new();
10094
10095        handle(
10096            &protocol,
10097            &state,
10098            &RenderOptions::new("index.html", "/"),
10099            &mut writer,
10100        )
10101        .unwrap();
10102
10103        let html = writer.get_content();
10104        assert!(
10105            html.contains("<p>hello</p>"),
10106            "Non-module component should render normally: {html}"
10107        );
10108    }
10109
10110    #[test]
10111    fn test_non_module_strategy_no_css_in_head() {
10112        // When component_css is empty (Link/Style strategies), no
10113        // CSS module importmap tags should appear in <head>.
10114        let template = r#"<p>hello</p>"#;
10115
10116        let mut fragments = HashMap::new();
10117        fragments.insert(
10118            "index.html".to_string(),
10119            FragmentList {
10120                fragments: vec![
10121                    WebUIFragment::raw("<html><head>".to_string()),
10122                    structural_fragment("head_end"),
10123                    WebUIFragment::raw("</head><body>".to_string()),
10124                    WebUIFragment::component("my-card"),
10125                    WebUIFragment::raw("</body></html>".to_string()),
10126                ],
10127                contains_boundary: false,
10128            },
10129        );
10130        fragments.insert(
10131            "my-card".to_string(),
10132            FragmentList {
10133                fragments: vec![WebUIFragment::raw(template.to_string())],
10134                contains_boundary: false,
10135            },
10136        );
10137
10138        // No component css populated — simulates Link/Style strategy
10139        let protocol = WebUIProtocol::new(fragments);
10140        let state = test_json!({});
10141        let mut writer = TestWriter::new();
10142
10143        handle(
10144            &protocol,
10145            &state,
10146            &RenderOptions::new("index.html", "/"),
10147            &mut writer,
10148        )
10149        .unwrap();
10150
10151        let html = writer.get_content();
10152
10153        assert!(
10154            !html.contains(r#"<style type="module""#),
10155            "Non-module strategy should not emit CSS module tags in <head>: {html}"
10156        );
10157        assert!(
10158            !html.contains(r#"<script type="importmap""#),
10159            "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
10160        );
10161        assert!(
10162            html.contains("<p>hello</p>"),
10163            "Component should still render: {html}"
10164        );
10165    }
10166
10167    #[test]
10168    fn test_style_strategy_embeds_inline_style_in_shadow_template() {
10169        let mut fragments = HashMap::new();
10170        fragments.insert(
10171            "index.html".to_string(),
10172            FragmentList {
10173                fragments: vec![
10174                    WebUIFragment::raw("<html><head>".to_string()),
10175                    structural_fragment("head_end"),
10176                    WebUIFragment::raw("</head><body><my-card>".to_string()),
10177                    WebUIFragment::component("my-card"),
10178                    WebUIFragment::raw("</my-card></body></html>".to_string()),
10179                ],
10180                contains_boundary: false,
10181            },
10182        );
10183        fragments.insert(
10184            "my-card".to_string(),
10185            FragmentList {
10186                fragments: vec![WebUIFragment::raw(
10187                    "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
10188                        .to_string(),
10189                )],
10190                        contains_boundary: false,
10191},
10192        );
10193
10194        let protocol = WebUIProtocol::new(fragments);
10195        let state = test_json!({});
10196        let mut writer = TestWriter::new();
10197
10198        handle(
10199            &protocol,
10200            &state,
10201            &RenderOptions::new("index.html", "/"),
10202            &mut writer,
10203        )
10204        .unwrap();
10205
10206        let html = writer.get_content();
10207
10208        assert!(
10209            html.contains("<style>.card{color:red}</style>"),
10210            "Style strategy should embed inline CSS in shadow template: {html}"
10211        );
10212        assert!(
10213            !html.contains(r#"<style type="module""#),
10214            "Style strategy should not emit legacy module CSS in <head>: {html}"
10215        );
10216        assert!(
10217            !html.contains(r#"<script type="importmap""#),
10218            "Style strategy should not emit CSS module importmaps in <head>: {html}"
10219        );
10220    }
10221
10222    #[test]
10223    fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
10224        // Light DOM + Link strategy: handler emits <link rel="stylesheet">
10225        // in <head>. No preload tag — the stylesheet itself fetches.
10226        let mut fragments = HashMap::new();
10227        fragments.insert(
10228            "index.html".to_string(),
10229            FragmentList {
10230                fragments: vec![
10231                    WebUIFragment::raw("<html><head>".to_string()),
10232                    structural_fragment("head_end"),
10233                    WebUIFragment::raw("</head><body><my-card>".to_string()),
10234                    WebUIFragment::component("my-card"),
10235                    WebUIFragment::raw("</my-card>".to_string()),
10236                    structural_fragment("body_end"),
10237                    WebUIFragment::raw("</body></html>".to_string()),
10238                ],
10239                contains_boundary: false,
10240            },
10241        );
10242        fragments.insert(
10243            "my-card".to_string(),
10244            FragmentList {
10245                fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
10246                contains_boundary: false,
10247            },
10248        );
10249
10250        let mut protocol = WebUIProtocol::new(fragments);
10251        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10252
10253        let comp = protocol
10254            .components
10255            .entry("my-card".to_string())
10256            .or_default();
10257        comp.css_href = "my-card.css".to_string();
10258        comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
10259        protocol.populate_style_closures(&["index.html"]);
10260
10261        let state = test_json!({});
10262        let mut writer = TestWriter::new();
10263
10264        handle(
10265            &protocol,
10266            &state,
10267            &RenderOptions::new("index.html", "/"),
10268            &mut writer,
10269        )
10270        .unwrap();
10271
10272        let html = writer.get_content();
10273
10274        let head_end = html.find("</head>").expect("</head> missing");
10275        let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css""#);
10276        assert!(
10277            link_pos.is_some_and(|p| p < head_end),
10278            "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
10279        );
10280        assert!(
10281            !html.contains(r#"<link rel="preload""#),
10282            "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
10283        );
10284        assert!(
10285            !html.contains(r#"<style type="module""#),
10286            "Link strategy should not emit legacy module CSS: {html}"
10287        );
10288        assert!(
10289            !html.contains(r#"<script type="importmap""#),
10290            "Link strategy should not emit CSS module importmaps: {html}"
10291        );
10292    }
10293
10294    #[test]
10295    fn link_strategy_emits_stylesheets_in_deterministic_document_order() {
10296        // Regression: `css_hrefs`/`style_specs` must follow the traversal-ordered
10297        // `reachable` list (a `Vec<String>`), never a `HashSet<String>` — whose
10298        // iteration order depends on the process's randomized hash seed and can
10299        // silently reorder `<link>` tags between renders/process restarts, which
10300        // in turn can flip cascade-order-sensitive CSS (e.g. same-specificity
10301        // `order`/`position` rules) and move on-page elements around.
10302        let mut fragments = HashMap::new();
10303        fragments.insert(
10304            "index.html".to_string(),
10305            FragmentList {
10306                fragments: vec![
10307                    WebUIFragment::raw("<html><head>".to_string()),
10308                    structural_fragment("head_end"),
10309                    WebUIFragment::raw(
10310                        "</head><body><comp-a></comp-a><comp-b></comp-b><comp-c></comp-c>"
10311                            .to_string(),
10312                    ),
10313                    WebUIFragment::component("comp-a"),
10314                    WebUIFragment::component("comp-b"),
10315                    WebUIFragment::component("comp-c"),
10316                    structural_fragment("body_end"),
10317                    WebUIFragment::raw("</body></html>".to_string()),
10318                ],
10319                contains_boundary: false,
10320            },
10321        );
10322        for tag in ["comp-a", "comp-b", "comp-c"] {
10323            fragments.insert(
10324                tag.to_string(),
10325                FragmentList {
10326                    fragments: vec![WebUIFragment::raw(format!("<div>{tag}</div>"))],
10327                    contains_boundary: false,
10328                },
10329            );
10330        }
10331
10332        let mut protocol = WebUIProtocol::new(fragments);
10333        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10334        for tag in ["comp-a", "comp-b", "comp-c"] {
10335            let comp = protocol.components.entry(tag.to_string()).or_default();
10336            comp.css_href = format!("{tag}.css");
10337            comp.template_json = format!(r#"{{"h":"<div>{tag}</div>"}}"#);
10338        }
10339        protocol.populate_style_closures(&["index.html"]);
10340
10341        let state = test_json!({});
10342        let mut writer = TestWriter::new();
10343        handle(
10344            &protocol,
10345            &state,
10346            &RenderOptions::new("index.html", "/"),
10347            &mut writer,
10348        )
10349        .unwrap();
10350        let html = writer.get_content();
10351
10352        let pos_a = html
10353            .find(r#"href="comp-a.css""#)
10354            .expect("comp-a.css link missing");
10355        let pos_b = html
10356            .find(r#"href="comp-b.css""#)
10357            .expect("comp-b.css link missing");
10358        let pos_c = html
10359            .find(r#"href="comp-c.css""#)
10360            .expect("comp-c.css link missing");
10361        assert!(
10362            pos_a < pos_b && pos_b < pos_c,
10363            "stylesheet <link> tags must follow document/traversal order \
10364             (comp-a, comp-b, comp-c), got positions {pos_a}, {pos_b}, {pos_c}: {html}"
10365        );
10366    }
10367
10368    #[test]
10369    fn link_strategy_preloads_static_shadow_css_in_head() {
10370        let mut fragments = HashMap::new();
10371        fragments.insert(
10372            "index.html".to_string(),
10373            FragmentList {
10374                fragments: vec![
10375                    WebUIFragment::raw("<html><head>".to_string()),
10376                    structural_fragment("head_end"),
10377                    WebUIFragment::raw("</head><body><my-card>".to_string()),
10378                    WebUIFragment::component("my-card"),
10379                    WebUIFragment::raw("</my-card></body></html>".to_string()),
10380                ],
10381                contains_boundary: false,
10382            },
10383        );
10384        fragments.insert(
10385            "o-loading-state".to_string(),
10386            FragmentList {
10387                fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
10388                contains_boundary: false,
10389            },
10390        );
10391        fragments.insert(
10392            "my-card".to_string(),
10393            FragmentList {
10394                fragments: vec![
10395                    WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10396                    structural_fragment("shadow_styles:my-card"),
10397                    WebUIFragment::raw("<div>card</div></template>".to_string()),
10398                ],
10399                contains_boundary: false,
10400            },
10401        );
10402
10403        let mut protocol = WebUIProtocol::new(fragments);
10404        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10405        let component = protocol
10406            .components
10407            .entry("my-card".to_string())
10408            .or_default();
10409        component.css_href = "my-card.css".to_string();
10410        component.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
10411        component.uses_shadow_dom = true;
10412        protocol.populate_style_closures(&["index.html"]);
10413
10414        let mut writer = TestWriter::new();
10415        handle(
10416            &protocol,
10417            &test_json!({}),
10418            &RenderOptions::new("index.html", "/"),
10419            &mut writer,
10420        )
10421        .unwrap();
10422
10423        let html = writer.get_content();
10424        let head_end = html.find("</head>").expect("</head> missing");
10425        let preload = html
10426            .find(r#"<link rel="preload" as="style" href="my-card.css">"#)
10427            .expect("Shadow preload missing");
10428        let stylesheet = html
10429            .find(r#"<link rel="stylesheet" href="my-card.css""#)
10430            .expect("Shadow stylesheet missing");
10431        assert!(preload < head_end);
10432        assert!(stylesheet > head_end);
10433        assert_eq!(html.matches(r#"rel="preload" as="style""#).count(), 1);
10434    }
10435
10436    #[test]
10437    fn tree_local_styles_deduplicate_per_root_and_resume_caller_frame() {
10438        let fragments = HashMap::from([
10439            (
10440                "index.html".to_string(),
10441                FragmentList {
10442                    fragments: vec![
10443                        WebUIFragment::raw("<html><head>".to_string()),
10444                        structural_fragment("head_end"),
10445                        WebUIFragment::raw("</head><body><outer-box>".to_string()),
10446                        WebUIFragment::component("outer-box"),
10447                        WebUIFragment::raw("</outer-box></body></html>".to_string()),
10448                    ],
10449                    contains_boundary: false,
10450                },
10451            ),
10452            (
10453                "outer-box".to_string(),
10454                FragmentList {
10455                    fragments: vec![
10456                        WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10457                        structural_fragment("shadow_styles:outer-box"),
10458                        WebUIFragment::raw("<light-card>".to_string()),
10459                        WebUIFragment::component("light-card"),
10460                        WebUIFragment::raw("</light-card><light-card>".to_string()),
10461                        WebUIFragment::component("light-card"),
10462                        WebUIFragment::raw("</light-card><inner-box>".to_string()),
10463                        WebUIFragment::component("inner-box"),
10464                        WebUIFragment::raw("</inner-box><light-card>".to_string()),
10465                        WebUIFragment::component("light-card"),
10466                        WebUIFragment::raw("</light-card></template>".to_string()),
10467                    ],
10468                    contains_boundary: false,
10469                },
10470            ),
10471            (
10472                "inner-box".to_string(),
10473                FragmentList {
10474                    fragments: vec![
10475                        WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10476                        structural_fragment("shadow_styles:inner-box"),
10477                        WebUIFragment::raw("<light-card>".to_string()),
10478                        WebUIFragment::component("light-card"),
10479                        WebUIFragment::raw("</light-card></template>".to_string()),
10480                    ],
10481                    contains_boundary: false,
10482                },
10483            ),
10484            (
10485                "light-card".to_string(),
10486                FragmentList {
10487                    fragments: vec![WebUIFragment::raw("<p>card</p>".to_string())],
10488                    contains_boundary: false,
10489                },
10490            ),
10491        ]);
10492        let mut protocol = WebUIProtocol::new(fragments);
10493        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10494        for (tag, css, uses_shadow_dom) in [
10495            ("outer-box", ".outer{}", true),
10496            ("inner-box", ".inner{}", true),
10497            ("light-card", ".card{}", false),
10498        ] {
10499            let component = protocol.components.entry(tag.to_string()).or_default();
10500            component.css = css.to_string();
10501            component.uses_shadow_dom = uses_shadow_dom;
10502        }
10503        protocol.populate_style_closures(&["index.html"]);
10504
10505        let mut writer = TestWriter::new();
10506        handle(
10507            &protocol,
10508            &test_json!({}),
10509            &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10510            &mut writer,
10511        )
10512        .unwrap();
10513        let html = writer.get_content();
10514
10515        assert_eq!(html.matches("data-webui-resource=\"outer-box\"").count(), 1);
10516        assert_eq!(html.matches("data-webui-resource=\"inner-box\"").count(), 1);
10517        assert_eq!(
10518            html.matches("data-webui-resource=\"light-card\"").count(),
10519            2,
10520            "the Light resource installs once in each Shadow tree: {html}"
10521        );
10522        assert_eq!(
10523            html.matches("data-webui-resource=").count(),
10524            4,
10525            "outer-box + light-card and inner-box + light-card are the four tree-local installs"
10526        );
10527        assert_eq!(
10528            html.matches("nonce=\"css-nonce\"").count(),
10529            4,
10530            "each inline style resource carries the nonce"
10531        );
10532        assert!(html.contains(r#"<meta name="webui-nonce" content="css-nonce">"#));
10533        let inner_end = html.find("</inner-box>").expect("inner host close");
10534        let caller_continuation = &html[inner_end..];
10535        let trailing_card = caller_continuation
10536            .find("<p>card</p>")
10537            .expect("caller continues after nested Shadow root");
10538        assert!(trailing_card > 0);
10539        assert!(
10540            !caller_continuation.contains("data-webui-resource=\"light-card\""),
10541            "returning from the nested Shadow root must restore the outer frame's delivered set"
10542        );
10543    }
10544
10545    #[test]
10546    fn routed_document_styles_are_hoisted_for_the_active_chain() {
10547        let route = WebUiFragmentRoute {
10548            path: "/".to_string(),
10549            fragment_id: "app-shell".to_string(),
10550            children: vec![
10551                WebUiFragmentRoute {
10552                    path: String::new(),
10553                    fragment_id: "dashboard-page".to_string(),
10554                    exact: true,
10555                    ..Default::default()
10556                },
10557                WebUiFragmentRoute {
10558                    path: "details".to_string(),
10559                    fragment_id: "detail-page".to_string(),
10560                    exact: true,
10561                    ..Default::default()
10562                },
10563            ],
10564            ..Default::default()
10565        };
10566        let fragments = HashMap::from([
10567            (
10568                "index.html".to_string(),
10569                FragmentList {
10570                    fragments: vec![
10571                        WebUIFragment::raw("<html><head>"),
10572                        structural_fragment("head_start"),
10573                        structural_fragment("head_end"),
10574                        WebUIFragment::raw("</head><body>"),
10575                        structural_fragment("body_start"),
10576                        WebUIFragment::boundary(0, "index.html", "boundary-0", None),
10577                        WebUIFragment::route_from(route),
10578                        WebUIFragment::boundary_end(0),
10579                        structural_fragment("body_end"),
10580                        WebUIFragment::raw("</body></html>"),
10581                    ],
10582                    contains_boundary: true,
10583                },
10584            ),
10585            (
10586                "app-shell".to_string(),
10587                FragmentList {
10588                    fragments: vec![
10589                        WebUIFragment::raw("<header>App</header>"),
10590                        WebUIFragment::outlet(),
10591                    ],
10592                    contains_boundary: false,
10593                },
10594            ),
10595            (
10596                "dashboard-page".to_string(),
10597                FragmentList {
10598                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10599                    contains_boundary: false,
10600                },
10601            ),
10602            (
10603                "detail-page".to_string(),
10604                FragmentList {
10605                    fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
10606                    contains_boundary: false,
10607                },
10608            ),
10609        ]);
10610        let mut protocol = WebUIProtocol::new(fragments);
10611        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10612        for tag in ["app-shell", "dashboard-page", "detail-page"] {
10613            let component = protocol.components.entry(tag.to_string()).or_default();
10614            component.css_href = format!("/{tag}.css");
10615            component.template_json = r#"{"h":""}"#.to_string();
10616        }
10617        protocol.populate_style_closures(&["index.html"]);
10618
10619        let mut writer = TestWriter::new();
10620        handle(
10621            &protocol,
10622            &test_json!({}),
10623            &RenderOptions::new("index.html", "/"),
10624            &mut writer,
10625        )
10626        .unwrap();
10627        let html = writer.get_content();
10628
10629        assert_eq!(
10630            html.matches(r#"data-webui-resource="app-shell""#).count(),
10631            1,
10632            "the matched route shell must install once: {html}"
10633        );
10634        assert_eq!(
10635            html.matches(r#"data-webui-resource="dashboard-page""#)
10636                .count(),
10637            1,
10638            "the matched child route must install once: {html}"
10639        );
10640        assert!(
10641            !html.contains(r#"data-webui-resource="detail-page""#),
10642            "inactive route CSS must not be delivered: {html}"
10643        );
10644        let head_end = html.find("</head>").expect("head close");
10645        let head = &html[..head_end];
10646        for (resource, href) in [
10647            ("app-shell", "/app-shell.css"),
10648            ("dashboard-page", "/dashboard-page.css"),
10649        ] {
10650            let encoded_href = crate::html_encode::encode_safe(href);
10651            assert!(
10652                head.contains(&format!(
10653                    r#"<link rel="stylesheet" href="{encoded_href}" data-webui-resource="{resource}" data-webui-strategy="link">"#
10654                )),
10655                "Document-targeted route CSS must be render-blocking in head: {html}"
10656            );
10657            assert!(
10658                !head.contains(&format!(
10659                    r#"<link rel="preload" as="style" href="{encoded_href}">"#
10660                )),
10661                "a hoisted stylesheet must not also be preloaded: {html}"
10662            );
10663        }
10664        assert!(
10665            !head.contains("detail-page.css"),
10666            "inactive route CSS must not be preloaded: {html}"
10667        );
10668        let app_style = html
10669            .find(r#"data-webui-resource="app-shell""#)
10670            .expect("app style");
10671        let dashboard_style = html
10672            .find(r#"data-webui-resource="dashboard-page""#)
10673            .expect("dashboard style");
10674        let app_host = html.find("<app-shell").expect("app route host");
10675        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10676        assert!(
10677            app_style < dashboard_style
10678                && dashboard_style < head_end
10679                && head_end < app_host
10680                && app_host < dashboard_host,
10681            "matched Document route closures must be hoisted in chain order: {html}"
10682        );
10683
10684        let protocol = Protocol::new(protocol);
10685        let mut streamed = FlushTestWriter::default();
10686        WebUIHandler::new()
10687            .render_streaming(
10688                &protocol,
10689                &test_json!({}),
10690                &RenderOptions::new("index.html", "/"),
10691                &mut streamed,
10692            )
10693            .unwrap();
10694        let streamed_head_end = streamed
10695            .output
10696            .find("</head>")
10697            .expect("streamed head close");
10698        for resource in ["app-shell", "dashboard-page"] {
10699            let marker = format!(r#"data-webui-resource="{resource}""#);
10700            let position = streamed.output.find(&marker).expect("streamed route style");
10701            assert!(
10702                position < streamed_head_end,
10703                "streaming must hoist {resource} into head: {}",
10704                streamed.output
10705            );
10706            assert_eq!(
10707                streamed.output.matches(&marker).count(),
10708                1,
10709                "streaming must not emit {resource} twice: {}",
10710                streamed.output
10711            );
10712        }
10713    }
10714
10715    #[test]
10716    fn bundled_routed_document_stylesheet_is_hoisted_once() {
10717        let route = WebUiFragmentRoute {
10718            path: "/".to_string(),
10719            fragment_id: "dashboard-page".to_string(),
10720            exact: true,
10721            ..Default::default()
10722        };
10723        let mut protocol = WebUIProtocol::new(HashMap::from([
10724            (
10725                "index.html".to_string(),
10726                FragmentList {
10727                    fragments: vec![
10728                        WebUIFragment::raw("<html><head>"),
10729                        structural_fragment("head_start"),
10730                        structural_fragment("head_end"),
10731                        WebUIFragment::raw("</head><body>"),
10732                        WebUIFragment::route_from(route),
10733                        structural_fragment("body_end"),
10734                        WebUIFragment::raw("</body></html>"),
10735                    ],
10736                    contains_boundary: false,
10737                },
10738            ),
10739            (
10740                "dashboard-page".to_string(),
10741                FragmentList {
10742                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10743                    contains_boundary: false,
10744                },
10745            ),
10746        ]));
10747        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10748        for tag in ["dashboard-page", "summary-card", "activity-list"] {
10749            protocol
10750                .components
10751                .entry(tag.to_string())
10752                .or_default()
10753                .css_href = format!("/{tag}.css");
10754        }
10755        protocol.style_closures.insert(
10756            "index.html".to_string(),
10757            webui_protocol::ComponentStyleClosure::default(),
10758        );
10759        protocol.style_closures.insert(
10760            "dashboard-page".to_string(),
10761            webui_protocol::ComponentStyleClosure {
10762                component_tags: vec![
10763                    "dashboard-page".to_string(),
10764                    "summary-card".to_string(),
10765                    "activity-list".to_string(),
10766                ],
10767                style_chunks: vec![0],
10768            },
10769        );
10770        protocol.style_chunks.push(webui_protocol::StyleChunk {
10771            name: "_chunk-dashboard-page-3".to_string(),
10772            css: String::new(),
10773            css_href: "/_chunk-dashboard-page-3.css".to_string(),
10774            component_tags: vec![
10775                "dashboard-page".to_string(),
10776                "summary-card".to_string(),
10777                "activity-list".to_string(),
10778            ],
10779        });
10780
10781        let mut writer = TestWriter::new();
10782        handle(
10783            &protocol,
10784            &test_json!({}),
10785            &RenderOptions::new("index.html", "/"),
10786            &mut writer,
10787        )
10788        .unwrap();
10789        let html = writer.get_content();
10790        let head_end = html.find("</head>").expect("head close");
10791        let marker = r#"data-webui-resource="_chunk-dashboard-page-3""#;
10792        let style = html.find(marker).expect("bundled route stylesheet");
10793
10794        assert!(
10795            style < head_end,
10796            "bundled route CSS must be in head: {html}"
10797        );
10798        assert_eq!(
10799            html.matches(marker).count(),
10800            1,
10801            "the route host must not emit the hoisted chunk again: {html}"
10802        );
10803        let bundled_href = crate::html_encode::encode_safe("/_chunk-dashboard-page-3.css");
10804        assert!(
10805            !html.contains(&format!(
10806                r#"<link rel="preload" as="style" href="{bundled_href}">"#
10807            )),
10808            "the applied bundled stylesheet must not also be preloaded: {html}"
10809        );
10810    }
10811
10812    #[test]
10813    fn routed_document_inline_styles_are_hoisted() {
10814        for strategy in [
10815            webui_protocol::CssStrategy::Style,
10816            webui_protocol::CssStrategy::Module,
10817        ] {
10818            let route = WebUiFragmentRoute {
10819                path: "/".to_string(),
10820                fragment_id: "dashboard-page".to_string(),
10821                exact: true,
10822                ..Default::default()
10823            };
10824            let mut protocol = WebUIProtocol::new(HashMap::from([
10825                (
10826                    "index.html".to_string(),
10827                    FragmentList {
10828                        fragments: vec![
10829                            WebUIFragment::raw("<html><head>"),
10830                            structural_fragment("head_start"),
10831                            structural_fragment("head_end"),
10832                            WebUIFragment::raw("</head><body>"),
10833                            WebUIFragment::route_from(route),
10834                            structural_fragment("body_end"),
10835                            WebUIFragment::raw("</body></html>"),
10836                        ],
10837                        contains_boundary: false,
10838                    },
10839                ),
10840                (
10841                    "dashboard-page".to_string(),
10842                    FragmentList {
10843                        fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10844                        contains_boundary: false,
10845                    },
10846                ),
10847            ]));
10848            protocol.set_css_strategy(strategy);
10849            protocol
10850                .components
10851                .entry("dashboard-page".to_string())
10852                .or_default()
10853                .css = ".dashboard{display:grid}".to_string();
10854            protocol.populate_style_closures(&["index.html"]);
10855
10856            let mut writer = TestWriter::new();
10857            handle(
10858                &protocol,
10859                &test_json!({}),
10860                &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10861                &mut writer,
10862            )
10863            .unwrap();
10864            let html = writer.get_content();
10865            let head_end = html.find("</head>").expect("head close");
10866            let marker = r#"data-webui-resource="dashboard-page""#;
10867            let style = html.find(marker).expect("route style fallback");
10868            let host = html.find("<dashboard-page").expect("dashboard route host");
10869            let strategy_name = if strategy == webui_protocol::CssStrategy::Module {
10870                "module"
10871            } else {
10872                "style"
10873            };
10874            let style_marker = format!(
10875                r#"data-webui-resource="dashboard-page" data-webui-strategy="{strategy_name}""#
10876            );
10877
10878            assert!(
10879                style < head_end && head_end < host,
10880                "{strategy:?} route CSS must be applied in head before its host: {html}"
10881            );
10882            assert_eq!(
10883                html.matches(&style_marker).count(),
10884                1,
10885                "{strategy:?} route CSS must not be emitted twice: {html}"
10886            );
10887        }
10888    }
10889
10890    #[test]
10891    fn light_route_inside_static_shadow_root_is_only_preloaded_in_head() {
10892        let route = WebUiFragmentRoute {
10893            path: "/".to_string(),
10894            fragment_id: "dashboard-page".to_string(),
10895            exact: true,
10896            ..Default::default()
10897        };
10898        let mut protocol = WebUIProtocol::new(HashMap::from([
10899            (
10900                "index.html".to_string(),
10901                FragmentList {
10902                    fragments: vec![
10903                        WebUIFragment::raw("<html><head>"),
10904                        structural_fragment("head_start"),
10905                        structural_fragment("head_end"),
10906                        WebUIFragment::raw("</head><body><outer-box>"),
10907                        WebUIFragment::component("outer-box"),
10908                        WebUIFragment::raw("</outer-box></body></html>"),
10909                    ],
10910                    contains_boundary: false,
10911                },
10912            ),
10913            (
10914                "outer-box".to_string(),
10915                FragmentList {
10916                    fragments: vec![
10917                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10918                        structural_fragment("shadow_styles:outer-box"),
10919                        WebUIFragment::route_from(route),
10920                        WebUIFragment::raw("</template>"),
10921                    ],
10922                    contains_boundary: false,
10923                },
10924            ),
10925            (
10926                "dashboard-page".to_string(),
10927                FragmentList {
10928                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10929                    contains_boundary: false,
10930                },
10931            ),
10932        ]));
10933        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10934        for tag in ["outer-box", "dashboard-page"] {
10935            protocol
10936                .components
10937                .entry(tag.to_string())
10938                .or_default()
10939                .css_href = format!("/{tag}.css");
10940        }
10941        protocol
10942            .components
10943            .get_mut("outer-box")
10944            .expect("outer-box component")
10945            .uses_shadow_dom = true;
10946        protocol.populate_style_closures(&["index.html"]);
10947
10948        let mut writer = TestWriter::new();
10949        handle(
10950            &protocol,
10951            &test_json!({}),
10952            &RenderOptions::new("index.html", "/"),
10953            &mut writer,
10954        )
10955        .unwrap();
10956        let html = writer.get_content();
10957        let head_end = html.find("</head>").expect("head close");
10958        let head = &html[..head_end];
10959        let dashboard_style = html
10960            .find(r#"data-webui-resource="dashboard-page""#)
10961            .expect("tree-local dashboard stylesheet");
10962        let shadow_start = html
10963            .find("<template shadowrootmode=\"open\">")
10964            .expect("Shadow template");
10965        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10966        let dashboard_href = crate::html_encode::encode_safe("/dashboard-page.css");
10967
10968        assert!(
10969            head.contains(&format!(
10970                r#"<link rel="preload" as="style" href="{dashboard_href}">"#
10971            )),
10972            "Shadow-targeted route CSS must start loading from head: {html}"
10973        );
10974        assert!(
10975            !head.contains(r#"data-webui-resource="dashboard-page""#),
10976            "Shadow-targeted route CSS must not be applied to Document: {html}"
10977        );
10978        assert!(
10979            shadow_start < dashboard_style && dashboard_style < dashboard_host,
10980            "the applying stylesheet must remain inside the owning ShadowRoot: {html}"
10981        );
10982    }
10983
10984    #[test]
10985    fn isolated_shadow_entry_does_not_emit_document_preloads() {
10986        let route = WebUiFragmentRoute {
10987            path: "/".to_string(),
10988            fragment_id: "dashboard-page".to_string(),
10989            exact: true,
10990            ..Default::default()
10991        };
10992        let mut protocol = WebUIProtocol::new(HashMap::from([
10993            (
10994                "outer-box".to_string(),
10995                FragmentList {
10996                    fragments: vec![
10997                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10998                        structural_fragment("shadow_styles:outer-box"),
10999                        WebUIFragment::route_from(route),
11000                        WebUIFragment::raw("</template>"),
11001                    ],
11002                    contains_boundary: false,
11003                },
11004            ),
11005            (
11006                "dashboard-page".to_string(),
11007                FragmentList {
11008                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11009                    contains_boundary: false,
11010                },
11011            ),
11012        ]));
11013        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11014        for tag in ["outer-box", "dashboard-page"] {
11015            protocol
11016                .components
11017                .entry(tag.to_string())
11018                .or_default()
11019                .css_href = format!("/{tag}.css");
11020        }
11021        protocol
11022            .components
11023            .get_mut("outer-box")
11024            .expect("outer-box component")
11025            .uses_shadow_dom = true;
11026        protocol.populate_style_closures(&["outer-box"]);
11027
11028        let mut writer = TestWriter::new();
11029        handle(
11030            &protocol,
11031            &test_json!({}),
11032            &RenderOptions::new("outer-box", "/"),
11033            &mut writer,
11034        )
11035        .unwrap();
11036        let html = writer.get_content();
11037
11038        assert!(
11039            html.starts_with("<template shadowrootmode=\"open\">"),
11040            "isolated component output must not be prefixed with document metadata: {html}"
11041        );
11042        assert!(
11043            !html.contains(r#"<link rel="preload""#),
11044            "an isolated Shadow entry has no Document head for preloads: {html}"
11045        );
11046        assert!(
11047            html.contains(r#"data-webui-resource="dashboard-page""#),
11048            "the route stylesheet must still install inside the ShadowRoot: {html}"
11049        );
11050    }
11051
11052    #[test]
11053    fn routed_shadow_styles_stay_tree_local_and_deduplicate_static_resources() {
11054        let route = WebUiFragmentRoute {
11055            path: "/".to_string(),
11056            fragment_id: "app-shell".to_string(),
11057            children: vec![
11058                WebUiFragmentRoute {
11059                    path: String::new(),
11060                    fragment_id: "dashboard-page".to_string(),
11061                    exact: true,
11062                    ..Default::default()
11063                },
11064                WebUiFragmentRoute {
11065                    path: "details".to_string(),
11066                    fragment_id: "detail-page".to_string(),
11067                    exact: true,
11068                    ..Default::default()
11069                },
11070            ],
11071            ..Default::default()
11072        };
11073        let fragments = HashMap::from([
11074            (
11075                "index.html".to_string(),
11076                FragmentList {
11077                    fragments: vec![
11078                        WebUIFragment::raw("<html><head>"),
11079                        structural_fragment("head_start"),
11080                        structural_fragment("head_end"),
11081                        WebUIFragment::raw("</head><body>"),
11082                        structural_fragment("body_start"),
11083                        WebUIFragment::boundary(0, "index.html", "boundary-0", None),
11084                        WebUIFragment::route_from(route),
11085                        WebUIFragment::boundary_end(0),
11086                        structural_fragment("body_end"),
11087                        WebUIFragment::raw("</body></html>"),
11088                    ],
11089                    contains_boundary: true,
11090                },
11091            ),
11092            (
11093                "app-shell".to_string(),
11094                FragmentList {
11095                    fragments: vec![
11096                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11097                        structural_fragment("shadow_styles:app-shell"),
11098                        WebUIFragment::raw("<shared-card"),
11099                        structural_fragment("streaming_root:shared-card"),
11100                        WebUIFragment::raw(">"),
11101                        WebUIFragment::component("shared-card"),
11102                        WebUIFragment::raw("</shared-card>"),
11103                        WebUIFragment::outlet(),
11104                        WebUIFragment::raw("</template>"),
11105                    ],
11106                    contains_boundary: false,
11107                },
11108            ),
11109            (
11110                "dashboard-page".to_string(),
11111                FragmentList {
11112                    fragments: vec![
11113                        WebUIFragment::raw("<shared-card"),
11114                        structural_fragment("streaming_root:shared-card"),
11115                        WebUIFragment::raw(">"),
11116                        WebUIFragment::component("shared-card"),
11117                        WebUIFragment::raw("</shared-card><main>Dashboard</main>"),
11118                    ],
11119                    contains_boundary: false,
11120                },
11121            ),
11122            (
11123                "detail-page".to_string(),
11124                FragmentList {
11125                    fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
11126                    contains_boundary: false,
11127                },
11128            ),
11129            (
11130                "shared-card".to_string(),
11131                FragmentList {
11132                    fragments: vec![WebUIFragment::raw("<p>Shared</p>")],
11133                    contains_boundary: false,
11134                },
11135            ),
11136        ]);
11137        let mut protocol = WebUIProtocol::new(fragments);
11138        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11139        for tag in ["app-shell", "dashboard-page", "detail-page", "shared-card"] {
11140            let component = protocol.components.entry(tag.to_string()).or_default();
11141            component.css = format!(".{tag}{{display:block}}");
11142            component.template_json = r#"{"h":""}"#.to_string();
11143        }
11144        protocol
11145            .components
11146            .get_mut("app-shell")
11147            .expect("app shell component")
11148            .uses_shadow_dom = true;
11149        protocol.populate_style_closures(&["index.html"]);
11150
11151        let mut writer = TestWriter::new();
11152        handle(
11153            &protocol,
11154            &test_json!({}),
11155            &RenderOptions::new("index.html", "/"),
11156            &mut writer,
11157        )
11158        .unwrap();
11159        let html = writer.get_content();
11160
11161        for resource in ["app-shell", "shared-card", "dashboard-page"] {
11162            assert_eq!(
11163                html.matches(&format!(r#"data-webui-resource="{resource}""#))
11164                    .count(),
11165                1,
11166                "{resource} must install once in the active ShadowRoot: {html}"
11167            );
11168        }
11169        assert!(
11170            !html.contains(r#"data-webui-resource="detail-page""#),
11171            "inactive route CSS must not enter the ShadowRoot: {html}"
11172        );
11173        let shadow_start = html
11174            .find("<template shadowrootmode=\"open\">")
11175            .expect("Shadow template");
11176        let dashboard_style = html
11177            .find(r#"data-webui-resource="dashboard-page""#)
11178            .expect("dashboard style");
11179        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
11180        let shadow_end = html.find("</template>").expect("Shadow template close");
11181        assert!(
11182            shadow_start < dashboard_style
11183                && dashboard_style < dashboard_host
11184                && dashboard_host < shadow_end,
11185            "active route CSS must precede its generated host inside the owning ShadowRoot: {html}"
11186        );
11187
11188        let protocol = Protocol::new(protocol);
11189        let mut streamed = FlushTestWriter::default();
11190        WebUIHandler::new()
11191            .render_streaming(
11192                &protocol,
11193                &test_json!({}),
11194                &RenderOptions::new("index.html", "/"),
11195                &mut streamed,
11196            )
11197            .unwrap();
11198        for resource in ["app-shell", "shared-card"] {
11199            assert_eq!(
11200                streamed
11201                    .output
11202                    .matches(&format!(r#"data-webui-resource="{resource}""#))
11203                    .count(),
11204                1,
11205                "streaming must preserve tree-local delivery for {resource}: {}",
11206                streamed.output
11207            );
11208        }
11209        assert!(
11210            !streamed
11211                .output
11212                .contains(r#"data-webui-resource="dashboard-page""#),
11213            "route CSS is carried by the streamed checkpoint rather than installed before the route host: {}",
11214            streamed.output
11215        );
11216        assert!(
11217            streamed
11218                .output
11219                .contains(r#""dashboard-page":{"css":".dashboard-page{display:block}""#)
11220                && streamed
11221                    .output
11222                    .contains(r#""dashboard-page":["dashboard-page","shared-card"]"#),
11223            "streamed route CSS metadata must preserve tree ownership and cascade order: {}",
11224            streamed.output
11225        );
11226        assert!(
11227            !streamed
11228                .output
11229                .contains(r#"data-webui-resource="detail-page""#),
11230            "streaming must omit inactive route CSS: {}",
11231            streamed.output
11232        );
11233    }
11234
11235    #[test]
11236    fn document_styles_follow_doctype_before_headless_document_content() {
11237        for entry_fragments in [
11238            vec![
11239                WebUIFragment::raw("<!DOCTYPE html><html><body>"),
11240                structural_fragment("body_start"),
11241                WebUIFragment::raw("<my-card>"),
11242                WebUIFragment::component("my-card"),
11243                WebUIFragment::raw("</my-card></body></html>"),
11244            ],
11245            vec![
11246                WebUIFragment::raw("<!doctype html><html><my-card>"),
11247                WebUIFragment::component("my-card"),
11248                WebUIFragment::raw("</my-card></html>"),
11249            ],
11250        ] {
11251            let mut protocol = WebUIProtocol::new(HashMap::from([
11252                (
11253                    "index.html".to_string(),
11254                    FragmentList {
11255                        fragments: entry_fragments,
11256                        contains_boundary: false,
11257                    },
11258                ),
11259                (
11260                    "my-card".to_string(),
11261                    FragmentList {
11262                        fragments: vec![WebUIFragment::raw("<p>card</p>")],
11263                        contains_boundary: false,
11264                    },
11265                ),
11266            ]));
11267            protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11268            let component = protocol
11269                .components
11270                .entry("my-card".to_string())
11271                .or_default();
11272            component.css = ".card{color:red}".to_string();
11273            component.uses_shadow_dom = false;
11274            protocol.populate_style_closures(&["index.html"]);
11275
11276            let mut writer = TestWriter::new();
11277            handle(
11278                &protocol,
11279                &test_json!({}),
11280                &RenderOptions::new("index.html", "/"),
11281                &mut writer,
11282            )
11283            .expect("render");
11284            let html = writer.get_content();
11285
11286            assert!(
11287                html.to_ascii_lowercase().starts_with("<!doctype html>"),
11288                "the doctype must remain the first document token: {html}"
11289            );
11290            let style = html.find("data-webui-resource=\"my-card\"").expect("style");
11291            let document = html.find("<html").expect("document root");
11292            let component = html.find("<my-card>").expect("component host");
11293            assert!(
11294                style < document && style < component,
11295                "headless document styles must precede document content: {html}"
11296            );
11297        }
11298    }
11299
11300    #[test]
11301    fn authored_shadow_component_can_render_as_entry_fragment() {
11302        let mut protocol = WebUIProtocol::new(HashMap::from([(
11303            "my-card".to_string(),
11304            FragmentList {
11305                fragments: vec![
11306                    WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11307                    structural_fragment("shadow_styles:my-card"),
11308                    WebUIFragment::raw("<p>card</p></template>"),
11309                ],
11310                contains_boundary: false,
11311            },
11312        )]));
11313        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11314        let component = protocol
11315            .components
11316            .entry("my-card".to_string())
11317            .or_default();
11318        component.css = ".card{color:red}".to_string();
11319        component.uses_shadow_dom = true;
11320        protocol.populate_style_closures(&["my-card"]);
11321
11322        let mut writer = TestWriter::new();
11323        handle(
11324            &protocol,
11325            &test_json!({}),
11326            &RenderOptions::new("my-card", "/"),
11327            &mut writer,
11328        )
11329        .expect("render");
11330        let html = writer.get_content();
11331
11332        assert!(html.starts_with("<template shadowrootmode=\"open\"><style "));
11333        assert_eq!(html.matches("data-webui-resource=\"my-card\"").count(), 1);
11334        assert!(html.ends_with("<p>card</p></template>"));
11335    }
11336
11337    #[test]
11338    fn component_style_closures_escape_mixed_case_style_end_tags() {
11339        for strategy in [
11340            webui_protocol::CssStrategy::Style,
11341            webui_protocol::CssStrategy::Module,
11342        ] {
11343            let mut protocol = WebUIProtocol::new(HashMap::from([
11344                (
11345                    "index.html".to_string(),
11346                    FragmentList {
11347                        fragments: vec![
11348                            WebUIFragment::raw("<html><head>"),
11349                            structural_fragment("head_end"),
11350                            WebUIFragment::raw("</head><body><safe-card>"),
11351                            WebUIFragment::component("safe-card"),
11352                            WebUIFragment::raw("</safe-card></body></html>"),
11353                        ],
11354                        contains_boundary: false,
11355                    },
11356                ),
11357                (
11358                    "safe-card".to_string(),
11359                    FragmentList {
11360                        fragments: vec![WebUIFragment::raw("<p>Safe</p>")],
11361                        contains_boundary: false,
11362                    },
11363                ),
11364            ]));
11365            protocol.set_css_strategy(strategy);
11366            protocol.components.insert(
11367                "safe-card".to_string(),
11368                webui_protocol::ComponentData {
11369                    css: ".safe{content:'</StYlE>'}".to_string(),
11370                    ..Default::default()
11371                },
11372            );
11373            protocol.populate_style_closures(&["index.html"]);
11374            let mut writer = TestWriter::new();
11375
11376            handle(
11377                &protocol,
11378                &test_json!({}),
11379                &RenderOptions::new("index.html", "/"),
11380                &mut writer,
11381            )
11382            .unwrap();
11383
11384            let html = writer.get_content();
11385            assert!(html.contains(".safe{content:'<\\/StYlE>'}"), "{html}");
11386            assert!(!html.contains(".safe{content:'</StYlE>'}"), "{html}");
11387        }
11388    }
11389
11390    #[test]
11391    fn shadow_closure_supports_link_and_module_ssr_paths() {
11392        for strategy in [
11393            webui_protocol::CssStrategy::Link,
11394            webui_protocol::CssStrategy::Module,
11395        ] {
11396            let mut protocol = WebUIProtocol::new(HashMap::from([
11397                (
11398                    "index.html".to_string(),
11399                    FragmentList {
11400                        fragments: vec![
11401                            WebUIFragment::raw("<html><head>".to_string()),
11402                            structural_fragment("head_end"),
11403                            WebUIFragment::raw("</head><body><my-card>".to_string()),
11404                            WebUIFragment::component("my-card"),
11405                            WebUIFragment::raw("</my-card></body></html>".to_string()),
11406                        ],
11407                        contains_boundary: false,
11408                    },
11409                ),
11410                (
11411                    "my-card".to_string(),
11412                    FragmentList {
11413                        fragments: vec![
11414                            WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
11415                            structural_fragment("shadow_styles:my-card"),
11416                            WebUIFragment::raw("<p>card</p></template>".to_string()),
11417                        ],
11418                        contains_boundary: false,
11419                    },
11420                ),
11421            ]));
11422            protocol.set_css_strategy(strategy);
11423            let component = protocol
11424                .components
11425                .entry("my-card".to_string())
11426                .or_default();
11427            component.css = ".card{color:red}".to_string();
11428            component.css_href = "/my-card.css".to_string();
11429            component.uses_shadow_dom = true;
11430            protocol.populate_style_closures(&["index.html"]);
11431
11432            let mut writer = TestWriter::new();
11433            handle(
11434                &protocol,
11435                &test_json!({}),
11436                &RenderOptions::new("index.html", "/"),
11437                &mut writer,
11438            )
11439            .unwrap();
11440            let html = writer.get_content();
11441            match strategy {
11442                webui_protocol::CssStrategy::Link => {
11443                    let escaped_href = crate::html_encode::encode_safe("/my-card.css");
11444                    assert!(html.contains(&format!(
11445                        "<template shadowrootmode=\"open\"><link rel=\"stylesheet\" href=\"{escaped_href}\" data-webui-resource=\"my-card\" data-webui-strategy=\"link\">"
11446                    )));
11447                }
11448                webui_protocol::CssStrategy::Module => {
11449                    assert!(html.contains(
11450                        "<template shadowrootmode=\"open\"><style data-webui-resource=\"my-card\" data-webui-strategy=\"module\">.card{color:red}</style>"
11451                    ));
11452                    assert_eq!(html.matches("<script type=\"importmap\"").count(), 1);
11453                }
11454                webui_protocol::CssStrategy::Style => unreachable!(),
11455            }
11456        }
11457    }
11458
11459    #[test]
11460    fn test_module_preloads_emit_in_head_in_compiler_order() {
11461        // The whole value of these hints is ordering: preloads are issued in
11462        // document order over one connection, so the largest chunk must go
11463        // first. The compiler sorts; the handler must not reorder or dedupe.
11464        let mut fragments = HashMap::new();
11465        fragments.insert(
11466            "index.html".to_string(),
11467            FragmentList {
11468                fragments: vec![
11469                    WebUIFragment::raw(
11470                        r#"<html><head><script type="module" async src="/index.js"></script>"#
11471                            .to_string(),
11472                    ),
11473                    structural_fragment("head_end"),
11474                    WebUIFragment::raw("</head><body>".to_string()),
11475                    structural_fragment("body_end"),
11476                    WebUIFragment::raw("</body></html>".to_string()),
11477                ],
11478                contains_boundary: false,
11479            },
11480        );
11481
11482        let mut protocol = WebUIProtocol::new(fragments);
11483        protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
11484
11485        let state = test_json!({});
11486        let mut writer = TestWriter::new();
11487        handle(
11488            &protocol,
11489            &state,
11490            &RenderOptions::new("index.html", "/"),
11491            &mut writer,
11492        )
11493        .unwrap();
11494
11495        let html = writer.get_content();
11496        let head_end = html.find("</head>").expect("</head> missing");
11497        let head = &html[..head_end];
11498        assert!(
11499            head.contains(
11500                r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
11501            ),
11502            "hints must appear in <head> in the compiler's order: {html}"
11503        );
11504    }
11505
11506    #[test]
11507    fn test_no_module_preloads_emits_nothing() {
11508        let mut fragments = HashMap::new();
11509        fragments.insert(
11510            "index.html".to_string(),
11511            FragmentList {
11512                fragments: vec![
11513                    WebUIFragment::raw("<html><head>".to_string()),
11514                    structural_fragment("head_end"),
11515                    WebUIFragment::raw("</head><body>".to_string()),
11516                    structural_fragment("body_end"),
11517                    WebUIFragment::raw("</body></html>".to_string()),
11518                ],
11519                contains_boundary: false,
11520            },
11521        );
11522        let protocol = WebUIProtocol::new(fragments);
11523
11524        let state = test_json!({});
11525        let mut writer = TestWriter::new();
11526        handle(
11527            &protocol,
11528            &state,
11529            &RenderOptions::new("index.html", "/"),
11530            &mut writer,
11531        )
11532        .unwrap();
11533
11534        assert!(
11535            !writer.get_content().contains("modulepreload"),
11536            "a build without hints must be byte-identical to before"
11537        );
11538    }
11539
11540    #[test]
11541    fn test_link_strategy_head_links_follow_document_order() {
11542        // Regression for #381: Link-strategy <head> CSS <link> tags must be
11543        // emitted in document/traversal order, not alphabetical tag order.
11544        // Document order here is <z-widget> then <a-widget>; an alphabetical
11545        // sort would (incorrectly) place a-widget first.
11546        let mut fragments = HashMap::new();
11547        fragments.insert(
11548            "index.html".to_string(),
11549            FragmentList {
11550                fragments: vec![
11551                    WebUIFragment::raw("<html><head>".to_string()),
11552                    structural_fragment("head_end"),
11553                    WebUIFragment::raw("</head><body><z-widget>".to_string()),
11554                    WebUIFragment::component("z-widget"),
11555                    WebUIFragment::raw("</z-widget><a-widget>".to_string()),
11556                    WebUIFragment::component("a-widget"),
11557                    WebUIFragment::raw("</a-widget>".to_string()),
11558                    structural_fragment("body_end"),
11559                    WebUIFragment::raw("</body></html>".to_string()),
11560                ],
11561                contains_boundary: false,
11562            },
11563        );
11564        fragments.insert(
11565            "z-widget".to_string(),
11566            FragmentList {
11567                fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
11568                contains_boundary: false,
11569            },
11570        );
11571        fragments.insert(
11572            "a-widget".to_string(),
11573            FragmentList {
11574                fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
11575                contains_boundary: false,
11576            },
11577        );
11578
11579        let mut protocol = WebUIProtocol::new(fragments);
11580        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11581
11582        let z = protocol
11583            .components
11584            .entry("z-widget".to_string())
11585            .or_default();
11586        z.css_href = "z-widget.css".to_string();
11587        z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
11588
11589        let a = protocol
11590            .components
11591            .entry("a-widget".to_string())
11592            .or_default();
11593        a.css_href = "a-widget.css".to_string();
11594        a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
11595        protocol.populate_style_closures(&["index.html"]);
11596
11597        let state = test_json!({});
11598        let mut writer = TestWriter::new();
11599
11600        handle(
11601            &protocol,
11602            &state,
11603            &RenderOptions::new("index.html", "/"),
11604            &mut writer,
11605        )
11606        .unwrap();
11607
11608        let html = writer.get_content();
11609        let head_end = html.find("</head>").expect("</head> missing");
11610        let head_section = &html[..head_end];
11611
11612        let z_pos = head_section
11613            .find(r#"<link rel="stylesheet" href="z-widget.css""#)
11614            .expect("z-widget stylesheet link missing from <head>");
11615        let a_pos = head_section
11616            .find(r#"<link rel="stylesheet" href="a-widget.css""#)
11617            .expect("a-widget stylesheet link missing from <head>");
11618
11619        assert!(
11620            z_pos < a_pos,
11621            "CSS <link> tags must follow document order (z-widget before \
11622             a-widget), not alphabetical order: {html}"
11623        );
11624    }
11625
11626    #[test]
11627    fn test_css_module_emitted_in_component_light_dom() {
11628        // CSS module <style> tags are emitted inline in the component's light DOM,
11629        // not in <head>. This keeps SSR output lean — only rendered components
11630        // get their style definitions.
11631        let mut fragments = HashMap::new();
11632        fragments.insert(
11633            "index.html".to_string(),
11634            FragmentList {
11635                fragments: vec![
11636                    WebUIFragment::raw("<html><head>".to_string()),
11637                    structural_fragment("head_end"),
11638                    WebUIFragment::raw("</head><body><my-card>".to_string()),
11639                    WebUIFragment::component("my-card"),
11640                    WebUIFragment::raw("</my-card>".to_string()),
11641                    WebUIFragment::raw("</body></html>".to_string()),
11642                ],
11643                contains_boundary: false,
11644            },
11645        );
11646        fragments.insert(
11647            "my-card".to_string(),
11648            FragmentList {
11649                fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
11650                contains_boundary: false,
11651            },
11652        );
11653
11654        let mut protocol = WebUIProtocol::new(fragments);
11655        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11656        protocol
11657            .components
11658            .entry("my-card".to_string())
11659            .or_default()
11660            .css = "p{color:red}".to_string();
11661        protocol.populate_style_closures(&["index.html"]);
11662        let state = test_json!({});
11663        let mut writer = TestWriter::new();
11664
11665        handle(
11666            &protocol,
11667            &state,
11668            &RenderOptions::new("index.html", "/"),
11669            &mut writer,
11670        )
11671        .unwrap();
11672
11673        let html = writer.get_content();
11674
11675        // CSS module importmap must be INSIDE the component tag (light DOM)
11676        let tag_open = html.find("<my-card>").expect("<my-card> missing");
11677        let css_pos = html
11678            .find(r#"<script type="importmap""#)
11679            .expect("CSS module importmap missing");
11680        let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
11681        assert!(
11682            css_pos > tag_open && css_pos < tag_close,
11683            "CSS module should be inside component light DOM: {html}"
11684        );
11685
11686        // <head> should NOT contain module styles
11687        let head_end = html.find("</head>").expect("</head> missing");
11688        assert!(
11689            css_pos > head_end,
11690            "CSS module should not be in <head>: {html}"
11691        );
11692    }
11693
11694    #[test]
11695    fn styled_protocol_without_closures_is_rejected_for_full_ssr() {
11696        let mut protocol = WebUIProtocol::default();
11697        protocol.fragments = HashMap::from([
11698            (
11699                "index.html".to_string(),
11700                FragmentList {
11701                    fragments: vec![
11702                        WebUIFragment::raw("<legacy-card>"),
11703                        WebUIFragment::component("legacy-card"),
11704                        WebUIFragment::raw("</legacy-card>"),
11705                    ],
11706                    contains_boundary: false,
11707                },
11708            ),
11709            (
11710                "legacy-card".to_string(),
11711                FragmentList {
11712                    fragments: vec![WebUIFragment::raw("<p>Legacy</p>")],
11713                    contains_boundary: false,
11714                },
11715            ),
11716        ]);
11717        protocol.components.insert(
11718            "legacy-card".to_string(),
11719            webui_protocol::ComponentData {
11720                css: ".legacy{color:red}".to_string(),
11721                ..Default::default()
11722            },
11723        );
11724        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11725        let mut writer = TestWriter::new();
11726
11727        let error = handle(
11728            &protocol,
11729            &test_json!({}),
11730            &RenderOptions::new("index.html", "/"),
11731            &mut writer,
11732        )
11733        .expect_err("styled protocols require closure metadata");
11734        assert!(error
11735            .to_string()
11736            .contains("component style closure metadata is required"));
11737    }
11738
11739    #[test]
11740    fn test_css_module_emitted_for_route_components() {
11741        // Route components get CSS modules emitted inline in their light DOM.
11742        let template = r#"<h1>Dashboard</h1>"#;
11743
11744        let mut fragments = HashMap::new();
11745        fragments.insert(
11746            "index.html".to_string(),
11747            FragmentList {
11748                fragments: vec![
11749                    WebUIFragment::raw("<html><head>".to_string()),
11750                    structural_fragment("head_end"),
11751                    WebUIFragment::raw("</head><body>".to_string()),
11752                    WebUIFragment::route("/", "dash-page"),
11753                    structural_fragment("body_end"),
11754                    WebUIFragment::raw("</body></html>".to_string()),
11755                ],
11756                contains_boundary: false,
11757            },
11758        );
11759        fragments.insert(
11760            "dash-page".to_string(),
11761            FragmentList {
11762                fragments: vec![WebUIFragment::raw(template.to_string())],
11763                contains_boundary: false,
11764            },
11765        );
11766
11767        let mut protocol = WebUIProtocol::new(fragments);
11768        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11769        let comp = protocol
11770            .components
11771            .entry("dash-page".to_string())
11772            .or_default();
11773        comp.css = "h1{font-size:2rem}".to_string();
11774        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
11775        protocol.populate_style_closures(&["index.html"]);
11776        let state = test_json!({});
11777        let mut writer = TestWriter::new();
11778
11779        handle(
11780            &protocol,
11781            &state,
11782            &RenderOptions::new("index.html", "/"),
11783            &mut writer,
11784        )
11785        .unwrap();
11786
11787        let html = writer.get_content();
11788
11789        assert!(
11790            html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
11791            "Route component should have CSS module importmap with data: URI: {html}"
11792        );
11793        assert!(
11794            html.contains("<h1>Dashboard</h1>"),
11795            "Route component should render content: {html}"
11796        );
11797        let fallback = html
11798            .find(r#"data-webui-resource="dash-page" data-webui-strategy="module""#)
11799            .expect("route Module fallback");
11800        let host_open = html.find("<dash-page>").expect("route host");
11801        let content = html.find("<h1>Dashboard</h1>").expect("route content");
11802        assert!(
11803            fallback < host_open && host_open < content,
11804            "the Module fallback must precede the generated Light route host: {html}"
11805        );
11806        // The importmap belongs to the component's own Light DOM, never to the
11807        // route element. The router only treats `<link>`/`<style>` markers as
11808        // route-owned styles, so an importmap emitted as a direct route child
11809        // would be cleared on navigation and mistaken for the mounted
11810        // component. Keep it inside the host so that can never happen.
11811        let importmap = html
11812            .find(r#"<script type="importmap""#)
11813            .expect("route CSS module importmap");
11814        assert!(
11815            host_open < importmap,
11816            "the CSS module importmap must live inside the route host, not as a route child: {html}"
11817        );
11818    }
11819
11820    #[test]
11821    fn test_head_css_link_skipped_for_components_without_css() {
11822        // Regression: components without CSS files must not get <link> tags
11823        // in <head>, otherwise the browser requests a 404.
11824        let mut fragments = HashMap::new();
11825        fragments.insert(
11826            "index.html".to_string(),
11827            FragmentList {
11828                fragments: vec![
11829                    WebUIFragment::raw("<html><head>".to_string()),
11830                    structural_fragment("head_end"),
11831                    WebUIFragment::raw("</head><body>".to_string()),
11832                    WebUIFragment::component("has-css"),
11833                    WebUIFragment::component("no-css"),
11834                    structural_fragment("body_end"),
11835                    WebUIFragment::raw("</body></html>".to_string()),
11836                ],
11837                contains_boundary: false,
11838            },
11839        );
11840        fragments.insert(
11841            "has-css".to_string(),
11842            FragmentList {
11843                fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
11844                contains_boundary: false,
11845            },
11846        );
11847        fragments.insert(
11848            "no-css".to_string(),
11849            FragmentList {
11850                fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
11851                contains_boundary: false,
11852            },
11853        );
11854
11855        let mut protocol = WebUIProtocol::new(fragments);
11856        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11857
11858        // Only has-css has an external stylesheet (Link strategy)
11859        protocol
11860            .components
11861            .entry("has-css".to_string())
11862            .or_default()
11863            .css_href = "has-css.css".to_string();
11864        protocol
11865            .components
11866            .entry("no-css".to_string())
11867            .or_default()
11868            .template_json = r#"{"h":"<p>plain</p>"}"#.to_string();
11869        protocol.populate_style_closures(&["index.html"]);
11870
11871        let state = test_json!({});
11872        let mut writer = TestWriter::new();
11873
11874        let handler = WebUIHandler::with_plugin(|| {
11875            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11876        });
11877        handler
11878            .handle(
11879                &protocol,
11880                &state,
11881                &RenderOptions::new("index.html", "/"),
11882                &mut writer,
11883            )
11884            .unwrap();
11885
11886        let html = writer.get_content();
11887        assert!(
11888            html.contains(r#"<link rel="stylesheet" href="has-css.css""#),
11889            "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
11890        );
11891        assert!(
11892            !html.contains("no-css.css"),
11893            "Component without CSS must NOT get a <link> in <head>: {html}"
11894        );
11895    }
11896
11897    #[test]
11898    fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
11899        // Simulates a page where app-shell renders cart-panel, but cart-panel
11900        // contains an <if> block with product-card inside. When the condition
11901        // is false (empty cart), product-card is NOT rendered — but it IS
11902        // reachable from the fragment graph. Its template metadata and CSS module
11903        // definition must be in the output so the client can mount it when
11904        // the <if> flips true. However, its bit must NOT be set in the
11905        // inventory — the inventory tracks what was actually rendered.
11906        let mut fragments = HashMap::new();
11907        fragments.insert(
11908            "index.html".to_string(),
11909            FragmentList {
11910                fragments: vec![
11911                    WebUIFragment::raw("<html><head>".to_string()),
11912                    structural_fragment("head_end"),
11913                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
11914                    WebUIFragment::component("app-shell"),
11915                    WebUIFragment::raw("</app-shell>".to_string()),
11916                    structural_fragment("body_end"),
11917                    WebUIFragment::raw("</body></html>".to_string()),
11918                ],
11919                contains_boundary: false,
11920            },
11921        );
11922        // app-shell contains a cart panel
11923        fragments.insert(
11924            "app-shell".to_string(),
11925            FragmentList {
11926                fragments: vec![
11927                    WebUIFragment::raw("<div>Shell</div>".to_string()),
11928                    WebUIFragment::component("cart-panel"),
11929                ],
11930                contains_boundary: false,
11931            },
11932        );
11933        // cart-panel has an <if> block containing product-card
11934        fragments.insert(
11935            "cart-panel".to_string(),
11936            FragmentList {
11937                fragments: vec![
11938                    WebUIFragment::raw("<aside>".to_string()),
11939                    WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
11940                    WebUIFragment::raw("</aside>".to_string()),
11941                ],
11942                contains_boundary: false,
11943            },
11944        );
11945        // cart-items (if block body) contains product-card
11946        fragments.insert(
11947            "cart-items".to_string(),
11948            FragmentList {
11949                fragments: vec![WebUIFragment::component("product-card")],
11950                contains_boundary: false,
11951            },
11952        );
11953        fragments.insert(
11954            "product-card".to_string(),
11955            FragmentList {
11956                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
11957                contains_boundary: false,
11958            },
11959        );
11960
11961        let mut protocol = WebUIProtocol::new(fragments);
11962        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11963        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11964        for name in ["app-shell", "cart-panel", "product-card"] {
11965            let comp = protocol.components.entry(name.to_string()).or_default();
11966            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
11967            comp.css = format!(".{name}{{display:block}}");
11968            if name == "cart-panel" {
11969                comp.hydration_mode = StateProjectionMode::Keys as i32;
11970                comp.hydration_keys = vec!["hasItems".to_string()];
11971            }
11972            if name == "product-card" {
11973                comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
11974            }
11975        }
11976        protocol.populate_style_closures(&["index.html"]);
11977
11978        // Render with hasItems=false — product-card should NOT be rendered
11979        let state = test_json!({ "hasItems": false });
11980        let mut writer = TestWriter::new();
11981
11982        let handler = WebUIHandler::with_plugin(|| {
11983            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11984        });
11985        handler
11986            .handle(
11987                &protocol,
11988                &state,
11989                &RenderOptions::new("index.html", "/"),
11990                &mut writer,
11991            )
11992            .unwrap();
11993
11994        let html = writer.get_content();
11995
11996        assert!(
11997            html.contains(r#"<script type="application/json" id="webui-data">"#),
11998            "non-executable SSR metadata should be emitted in the webui-data block: {html}"
11999        );
12000        assert!(
12001            html.contains(r#""state":{"hasItems":false}"#),
12002            "SSR state should live in the JSON data block: {html}"
12003        );
12004        assert!(
12005            html.contains(r#""inventory":"#),
12006            "SSR inventory should live in the JSON data block: {html}"
12007        );
12008        assert!(
12009            !html.contains("window.__webui={\""),
12010            "executable bootstrap must not embed the window.__webui JSON literal: {html}"
12011        );
12012        assert!(
12013            !html.contains(r#"document.getElementById("webui-data")"#),
12014            "SSR must not parse webui-data; client packages own that lazy load: {html}"
12015        );
12016        assert!(
12017            !html.contains("window.__webui=w;"),
12018            "executable bootstrap must not replace existing window.__webui registrations: {html}"
12019        );
12020        assert!(
12021            !html.contains("w.templateFns={\""),
12022            "template function emission must not replace existing templateFns registrations: {html}"
12023        );
12024        assert!(
12025            html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
12026            "template functions should merge into the flat templateFns registry: {html}"
12027        );
12028
12029        // product-card template IS in the output — it's a known component
12030        // whose template must be available for client-side <if> activation.
12031        assert!(
12032            html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
12033            "product-card template should be emitted even when unrendered: {html}"
12034        );
12035
12036        // product-card CSS module IS in the output — reachable components need
12037        // their stylesheet definitions for client-side <if> activation.
12038        assert!(
12039            html.contains(r#""product-card":"data:text/css,"#),
12040            "reachable product-card CSS module importmap should be emitted: {html}"
12041        );
12042
12043        // app-shell and cart-panel SHOULD be in the output (they were rendered)
12044        assert!(
12045            html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
12046            "rendered app-shell template should be emitted: {html}"
12047        );
12048        assert!(
12049            html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
12050            "rendered cart-panel template should be emitted: {html}"
12051        );
12052    }
12053
12054    #[test]
12055    fn component_asset_manifest_falls_back_to_body_start() {
12056        let fragments = HashMap::from([(
12057            "index.html".to_string(),
12058            FragmentList {
12059                fragments: vec![
12060                    WebUIFragment::raw("<body>".to_string()),
12061                    structural_fragment("body_start"),
12062                    WebUIFragment::raw("<main>Body-only host</main></body>".to_string()),
12063                ],
12064                contains_boundary: false,
12065            },
12066        )]);
12067        let mut protocol = WebUIProtocol::new(fragments);
12068        protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
12069            root: "lazy-panel".to_string(),
12070            style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
12071        }];
12072        let mut writer = TestWriter::new();
12073
12074        handle(
12075            &protocol,
12076            &Value::Null,
12077            &RenderOptions::new("index.html", "/"),
12078            &mut writer,
12079        )
12080        .unwrap();
12081
12082        assert_eq!(
12083            writer.get_content(),
12084            r#"<body><script type="application/json" id="webui-component-assets">{"lazy-panel":["/assets/lazy-panel.css"]}</script><main>Body-only host</main></body>"#
12085        );
12086    }
12087
12088    #[test]
12089    fn component_asset_styles_emit_once_when_body_precedes_head() {
12090        let fragments = HashMap::from([(
12091            "index.html".to_string(),
12092            FragmentList {
12093                fragments: vec![
12094                    WebUIFragment::raw("<body>".to_string()),
12095                    structural_fragment("body_start"),
12096                    WebUIFragment::raw("</body><head>".to_string()),
12097                    structural_fragment("head_end"),
12098                    WebUIFragment::raw("</head>".to_string()),
12099                ],
12100                contains_boundary: false,
12101            },
12102        )]);
12103        let mut protocol = WebUIProtocol::new(fragments);
12104        protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
12105            root: "lazy-panel".to_string(),
12106            style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
12107        }];
12108        let mut writer = TestWriter::new();
12109
12110        handle(
12111            &protocol,
12112            &Value::Null,
12113            &RenderOptions::new("index.html", "/"),
12114            &mut writer,
12115        )
12116        .unwrap();
12117
12118        assert_eq!(
12119            writer
12120                .get_content()
12121                .matches(r#"id="webui-component-assets""#)
12122                .count(),
12123            1
12124        );
12125    }
12126
12127    #[test]
12128    fn light_component_asset_styles_apply_at_head_end() {
12129        let fragments = HashMap::from([(
12130            "index.html".to_string(),
12131            FragmentList {
12132                fragments: vec![
12133                    WebUIFragment::raw("<html><head>".to_string()),
12134                    structural_fragment("head_end"),
12135                    WebUIFragment::raw("</head><body></body></html>".to_string()),
12136                ],
12137                contains_boundary: false,
12138            },
12139        )]);
12140        let mut protocol = WebUIProtocol::new(fragments);
12141        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
12142        for root in ["lazy-panel", "secondary-panel"] {
12143            protocol.components.insert(
12144                root.to_string(),
12145                webui_protocol::ComponentData {
12146                    uses_shadow_dom: false,
12147                    ..Default::default()
12148                },
12149            );
12150        }
12151        protocol.component_asset_style_preloads = vec![
12152            ComponentAssetStylePreload {
12153                root: "lazy-panel".to_string(),
12154                style_hrefs: vec![
12155                    "/assets/lazy-panel.css".to_string(),
12156                    "/assets/shared.css".to_string(),
12157                ],
12158            },
12159            ComponentAssetStylePreload {
12160                root: "secondary-panel".to_string(),
12161                style_hrefs: vec![
12162                    "/assets/secondary-panel.css".to_string(),
12163                    "/assets/shared.css".to_string(),
12164                ],
12165            },
12166        ];
12167        let mut writer = TestWriter::new();
12168
12169        handle(
12170            &protocol,
12171            &Value::Null,
12172            &RenderOptions::new("index.html", "/"),
12173            &mut writer,
12174        )
12175        .unwrap();
12176
12177        let html = writer.get_content();
12178        assert_eq!(html.matches(r#"rel="stylesheet""#).count(), 3);
12179        assert_eq!(html.matches("/assets/shared.css").count(), 1);
12180        assert!(!html.contains(COMPONENT_ASSET_MANIFEST_ID));
12181        let links_end = html
12182            .find(r#"<link rel="stylesheet" href="/assets/secondary-panel.css">"#)
12183            .expect("asset stylesheet must be emitted");
12184        let head_end = html.find("</head>").expect("head must close");
12185        assert!(links_end < head_end);
12186    }
12187
12188    // ── CSP nonce on CSS module importmap ───────────────────────────
12189    //
12190    // When `RenderOptions::with_nonce(...)` is set, every inline
12191    // `<script type="importmap">` definition emitted during SSR for a
12192    // component CSS module must include `nonce="VALUE"` so strict CSP
12193    // `script-src 'nonce-...'` policies allow it. The without-nonce case
12194    // is already covered by other CSS module tests (e.g.
12195    // `test_css_module_emitted_for_route_components`).
12196
12197    #[test]
12198    fn test_css_module_emits_nonce_attribute_when_nonce_set() {
12199        // Per-component first-render path (`emit_css_module`).
12200        let template = r#"<h1>Dashboard</h1>"#;
12201
12202        let mut fragments = HashMap::new();
12203        fragments.insert(
12204            "index.html".to_string(),
12205            FragmentList {
12206                fragments: vec![
12207                    WebUIFragment::raw("<html><head>".to_string()),
12208                    structural_fragment("head_end"),
12209                    WebUIFragment::raw("</head><body>".to_string()),
12210                    WebUIFragment::route("/", "dash-page"),
12211                    structural_fragment("body_end"),
12212                    WebUIFragment::raw("</body></html>".to_string()),
12213                ],
12214                contains_boundary: false,
12215            },
12216        );
12217        fragments.insert(
12218            "dash-page".to_string(),
12219            FragmentList {
12220                fragments: vec![WebUIFragment::raw(template.to_string())],
12221                contains_boundary: false,
12222            },
12223        );
12224
12225        let mut protocol = WebUIProtocol::new(fragments);
12226        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12227        let comp = protocol
12228            .components
12229            .entry("dash-page".to_string())
12230            .or_default();
12231        comp.css = "h1{font-size:2rem}".to_string();
12232        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
12233        protocol.populate_style_closures(&["index.html"]);
12234        let state = test_json!({});
12235        let mut writer = TestWriter::new();
12236
12237        handle(
12238            &protocol,
12239            &state,
12240            &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
12241            &mut writer,
12242        )
12243        .unwrap();
12244
12245        let html = writer.get_content();
12246
12247        assert!(
12248            html.contains(
12249                r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="dash-page">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
12250            ),
12251            "CSS module importmap tag should include nonce attribute in canonical order: {html}"
12252        );
12253    }
12254
12255    #[test]
12256    fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
12257        // Body-end emission path for reachable-but-unrendered components
12258        // (the second site touched by the patch). Triggered via a false
12259        // `<if>` block under hydration; requires the WebUI plugin so the
12260        // body_end hook executes.
12261        let mut fragments = HashMap::new();
12262        fragments.insert(
12263            "index.html".to_string(),
12264            FragmentList {
12265                fragments: vec![
12266                    WebUIFragment::raw("<html><head>".to_string()),
12267                    structural_fragment("head_end"),
12268                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
12269                    WebUIFragment::component("app-shell"),
12270                    WebUIFragment::raw("</app-shell>".to_string()),
12271                    structural_fragment("body_end"),
12272                    WebUIFragment::raw("</body></html>".to_string()),
12273                ],
12274                contains_boundary: false,
12275            },
12276        );
12277        fragments.insert(
12278            "app-shell".to_string(),
12279            FragmentList {
12280                fragments: vec![WebUIFragment::if_cond(
12281                    ConditionExpr::identifier("hasItems"),
12282                    "cart-items",
12283                )],
12284                contains_boundary: false,
12285            },
12286        );
12287        fragments.insert(
12288            "cart-items".to_string(),
12289            FragmentList {
12290                fragments: vec![WebUIFragment::component("product-card")],
12291                contains_boundary: false,
12292            },
12293        );
12294        fragments.insert(
12295            "product-card".to_string(),
12296            FragmentList {
12297                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
12298                contains_boundary: false,
12299            },
12300        );
12301
12302        let mut protocol = WebUIProtocol::new(fragments);
12303        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12304        for name in ["app-shell", "product-card"] {
12305            let comp = protocol.components.entry(name.to_string()).or_default();
12306            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
12307            comp.css = format!(".{name}{{display:block}}");
12308        }
12309        protocol.populate_style_closures(&["index.html"]);
12310
12311        // Render with hasItems=false so product-card is reachable but not
12312        // rendered, forcing its CSS module emission through the body_end path.
12313        let state = test_json!({ "hasItems": false });
12314        let mut writer = TestWriter::new();
12315
12316        let handler = WebUIHandler::with_plugin(|| {
12317            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12318        });
12319        handler
12320            .handle(
12321                &protocol,
12322                &state,
12323                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
12324                &mut writer,
12325            )
12326            .unwrap();
12327
12328        let html = writer.get_content();
12329
12330        assert!(
12331            html.contains(
12332                r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="product-card">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
12333            ),
12334            "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
12335        );
12336    }
12337
12338    #[test]
12339    fn projected_state_excludes_non_hydration_keys() -> Result<()> {
12340        let mut fragments = HashMap::new();
12341        fragments.insert(
12342            "index.html".to_string(),
12343            FragmentList {
12344                fragments: vec![
12345                    WebUIFragment::raw("<html><body><style>".to_string()),
12346                    WebUIFragment::signal("tokens.light", true),
12347                    WebUIFragment::raw("</style>".to_string()),
12348                    structural_fragment("body_end"),
12349                    WebUIFragment::raw("</body></html>".to_string()),
12350                ],
12351                contains_boundary: false,
12352            },
12353        );
12354        fragments.insert(
12355            "app-shell".to_string(),
12356            FragmentList {
12357                fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
12358                contains_boundary: false,
12359            },
12360        );
12361        let index_fragments = fragments
12362            .get_mut("index.html")
12363            .expect("index fixture should exist");
12364        index_fragments
12365            .fragments
12366            .insert(1, WebUIFragment::component("app-shell"));
12367        let mut protocol = WebUIProtocol::new(fragments);
12368        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12369        // Only `name` is a hydration key. `tokens` is a server-only field
12370        // (used above to resolve SSR CSS variables) and is NOT in the component
12371        // hydration keys,
12372        // so projection MUST keep it out of the client state block.
12373        protocol.components.insert(
12374            "app-shell".to_string(),
12375            webui_protocol::ComponentData {
12376                hydration_mode: StateProjectionMode::Keys as i32,
12377                hydration_keys: vec!["name".to_string()],
12378                ..Default::default()
12379            },
12380        );
12381        let state = test_json!({
12382            "name": "Alice",
12383            "tokens": {
12384                "light": "--color-brand: red;"
12385            }
12386        });
12387        let mut writer = TestWriter::new();
12388        let handler = WebUIHandler::with_plugin(|| {
12389            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12390        });
12391        handler.handle(
12392            &protocol,
12393            &state,
12394            &RenderOptions::new("index.html", "/"),
12395            &mut writer,
12396        )?;
12397        let output = writer.get_content();
12398
12399        // SSR still reads `tokens` to resolve the inline <style>...
12400        assert!(output.contains("--color-brand: red;"));
12401        // ...but only the hydration key reaches the client state.
12402        assert!(output.contains(r#""name":"Alice""#));
12403        assert!(!output.contains(r#""tokens""#));
12404        Ok(())
12405    }
12406
12407    #[test]
12408    fn full_initial_strategy_preserves_complete_state() -> Result<()> {
12409        let mut fragments = HashMap::new();
12410        fragments.insert(
12411            "index.html".to_string(),
12412            FragmentList {
12413                fragments: vec![
12414                    WebUIFragment::raw("<html><body>"),
12415                    structural_fragment("body_end"),
12416                    WebUIFragment::raw("</body></html>"),
12417                ],
12418                contains_boundary: false,
12419            },
12420        );
12421        let protocol = WebUIProtocol::new(fragments);
12422        let state = test_json!({
12423            "client": "visible",
12424            "serverOnly": "also preserved",
12425        });
12426        let handler = WebUIHandler::with_plugin(|| {
12427            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12428        });
12429        let mut writer = TestWriter::new();
12430        handler.handle(
12431            &protocol,
12432            &state,
12433            &RenderOptions::new("index.html", "/"),
12434            &mut writer,
12435        )?;
12436        let output = writer.get_content();
12437        assert!(output.contains(r#""client":"visible""#));
12438        assert!(output.contains(r#""serverOnly":"also preserved""#));
12439        Ok(())
12440    }
12441
12442    #[test]
12443    fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
12444        let mut fragments = HashMap::new();
12445        fragments.insert(
12446            "index.html".to_string(),
12447            FragmentList {
12448                fragments: vec![
12449                    WebUIFragment::raw("<html><body>"),
12450                    WebUIFragment::component("app-shell"),
12451                    structural_fragment("body_end"),
12452                    WebUIFragment::raw("</body></html>"),
12453                ],
12454                contains_boundary: false,
12455            },
12456        );
12457        fragments.insert(
12458            "app-shell".to_string(),
12459            FragmentList {
12460                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12461                contains_boundary: false,
12462            },
12463        );
12464        let mut protocol = WebUIProtocol::new(fragments);
12465        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12466        protocol.components.insert(
12467            "app-shell".to_string(),
12468            webui_protocol::ComponentData {
12469                hydration_mode: StateProjectionMode::All as i32,
12470                ..Default::default()
12471            },
12472        );
12473        let state = test_json!({
12474            "known": "value",
12475            "possiblyInherited": "must not be dropped",
12476        });
12477        let handler = WebUIHandler::with_plugin(|| {
12478            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12479        });
12480        let mut writer = TestWriter::new();
12481        handler.handle(
12482            &protocol,
12483            &state,
12484            &RenderOptions::new("index.html", "/"),
12485            &mut writer,
12486        )?;
12487        let output = writer.get_content();
12488        assert!(output.contains(r#""known":"value""#));
12489        assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
12490        Ok(())
12491    }
12492
12493    #[test]
12494    fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
12495        let mut fragments = HashMap::new();
12496        fragments.insert(
12497            "index.html".to_string(),
12498            FragmentList {
12499                fragments: vec![
12500                    WebUIFragment::raw("<html><body>"),
12501                    WebUIFragment::component("app-shell"),
12502                    structural_fragment("body_end"),
12503                    WebUIFragment::raw("</body></html>"),
12504                ],
12505                contains_boundary: false,
12506            },
12507        );
12508        fragments.insert(
12509            "app-shell".to_string(),
12510            FragmentList {
12511                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12512                contains_boundary: false,
12513            },
12514        );
12515        let mut protocol = WebUIProtocol::new(fragments);
12516        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12517        let state = test_json!({
12518            "known": "value",
12519            "serverOnly": "must not be dropped",
12520        });
12521        let handler = WebUIHandler::with_plugin(|| {
12522            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12523        });
12524        let mut writer = TestWriter::new();
12525        handler.handle(
12526            &protocol,
12527            &state,
12528            &RenderOptions::new("index.html", "/"),
12529            &mut writer,
12530        )?;
12531        let output = writer.get_content();
12532        assert!(output.contains(r#""known":"value""#));
12533        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12534        Ok(())
12535    }
12536
12537    #[test]
12538    fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
12539        let mut fragments = HashMap::new();
12540        fragments.insert(
12541            "index.html".to_string(),
12542            FragmentList {
12543                fragments: vec![
12544                    WebUIFragment::raw("<html><body>"),
12545                    WebUIFragment::component("app-shell"),
12546                    structural_fragment("body_end"),
12547                    WebUIFragment::raw("</body></html>"),
12548                ],
12549                contains_boundary: false,
12550            },
12551        );
12552        fragments.insert(
12553            "app-shell".to_string(),
12554            FragmentList {
12555                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12556                contains_boundary: false,
12557            },
12558        );
12559        let mut protocol = WebUIProtocol::new(fragments);
12560        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12561        protocol.components.insert(
12562            "app-shell".to_string(),
12563            webui_protocol::ComponentData {
12564                hydration_mode: i32::MAX,
12565                ..Default::default()
12566            },
12567        );
12568        let state = test_json!({
12569            "known": "value",
12570            "serverOnly": "must not be dropped",
12571        });
12572        let handler = WebUIHandler::with_plugin(|| {
12573            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12574        });
12575        let mut writer = TestWriter::new();
12576        handler.handle(
12577            &protocol,
12578            &state,
12579            &RenderOptions::new("index.html", "/"),
12580            &mut writer,
12581        )?;
12582        let output = writer.get_content();
12583        assert!(output.contains(r#""known":"value""#));
12584        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12585        Ok(())
12586    }
12587
12588    #[test]
12589    fn legacy_navigation_keys_with_default_mode_remain_keyed() {
12590        let mut protocol = WebUIProtocol::new(HashMap::new());
12591        protocol.components.insert(
12592            "app-shell".to_string(),
12593            webui_protocol::ComponentData {
12594                navigation_keys: vec!["selected".to_string()],
12595                ..Default::default()
12596            },
12597        );
12598
12599        match collect_navigation_state(&protocol, ["app-shell"]) {
12600            StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
12601            StateSelection::Full
12602            | StateSelection::KeyIds(_)
12603            | StateSelection::FullExceptKeyIds(_) => {
12604                panic!("legacy navigation keys should remain owned and projected")
12605            }
12606        }
12607    }
12608
12609    #[test]
12610    fn legacy_navigation_without_projection_metadata_preserves_full_state() {
12611        let mut protocol = WebUIProtocol::new(HashMap::new());
12612        protocol.components.insert(
12613            "app-shell".to_string(),
12614            webui_protocol::ComponentData::default(),
12615        );
12616
12617        assert!(matches!(
12618            collect_navigation_state(&protocol, ["app-shell"]),
12619            StateSelection::Full
12620        ));
12621    }
12622
12623    #[test]
12624    fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
12625        let mut fragments = HashMap::new();
12626        fragments.insert(
12627            "index.html".to_string(),
12628            FragmentList {
12629                fragments: vec![
12630                    WebUIFragment::raw("<html><body>".to_string()),
12631                    structural_fragment("body_end"),
12632                    WebUIFragment::raw("</body></html>".to_string()),
12633                ],
12634                contains_boundary: false,
12635            },
12636        );
12637        let mut protocol = WebUIProtocol::new(fragments);
12638        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12639        let state = test_json!({
12640            "title": "Legacy state",
12641            "serverOnly": "preserved",
12642        });
12643        let handler = WebUIHandler::with_plugin(|| {
12644            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12645        });
12646
12647        let mut writer = TestWriter::new();
12648        handler.handle(
12649            &protocol,
12650            &state,
12651            &RenderOptions::new("index.html", "/"),
12652            &mut writer,
12653        )?;
12654        assert!(writer.get_content().contains(r#""state":{}"#));
12655        assert!(!writer.get_content().contains("Legacy state"));
12656        assert!(!writer.get_content().contains("preserved"));
12657        Ok(())
12658    }
12659
12660    #[test]
12661    fn scriptless_component_state_is_navigation_only() -> Result<()> {
12662        let mut fragments = HashMap::new();
12663        fragments.insert(
12664            "index.html".to_string(),
12665            FragmentList {
12666                fragments: vec![
12667                    WebUIFragment::raw("<html><body>"),
12668                    WebUIFragment::component("items-page"),
12669                    structural_fragment("body_end"),
12670                    WebUIFragment::raw("</body></html>"),
12671                ],
12672                contains_boundary: false,
12673            },
12674        );
12675        fragments.insert(
12676            "items-page".to_string(),
12677            FragmentList {
12678                fragments: vec![WebUIFragment::raw("<p>Items</p>")],
12679                contains_boundary: false,
12680            },
12681        );
12682        let mut protocol = WebUIProtocol::new(fragments);
12683        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12684        protocol.components.insert(
12685            "items-page".to_string(),
12686            webui_protocol::ComponentData {
12687                template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
12688                navigation_mode: Some(StateProjectionMode::Keys as i32),
12689                navigation_keys: vec!["items".into()],
12690                ..Default::default()
12691            },
12692        );
12693        let state = test_json!({
12694            "items": ["STATE_SENTINEL"],
12695            "serverOnly": "SECRET_SENTINEL",
12696        });
12697        let handler = WebUIHandler::with_plugin(|| {
12698            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12699        });
12700        let mut writer = TestWriter::new();
12701
12702        handler.handle(
12703            &protocol,
12704            &state,
12705            &RenderOptions::new("index.html", "/"),
12706            &mut writer,
12707        )?;
12708
12709        let output = writer.get_content();
12710        assert!(output.contains(r#""state":{}"#));
12711        assert!(!output.contains("STATE_SENTINEL"));
12712        assert!(!output.contains("SECRET_SENTINEL"));
12713        Ok(())
12714    }
12715
12716    #[test]
12717    fn write_selected_state_projects_and_escapes() {
12718        // `keep` is in the sorted key set and its value contains a `</` that
12719        // must be escaped; `drop` is absent and must be projected out.
12720        let state = test_json!({
12721            "drop": "secret",
12722            "keep": "</script><b>"
12723        });
12724        let keys = ["keep"];
12725        let selection = StateSelection::Keys(keys.to_vec());
12726        let mut sink = TestWriter::new();
12727        let mut scratch = Vec::new();
12728        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12729        assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
12730    }
12731
12732    #[test]
12733    fn write_selected_state_non_object_projection_emits_empty_object() {
12734        let state = test_json!("scalar state has nothing hydratable");
12735        let keys: [&str; 0] = [];
12736        let selection = StateSelection::Keys(keys.to_vec());
12737        let mut sink = TestWriter::new();
12738        let mut scratch = Vec::new();
12739        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12740        assert_eq!(sink.get_content(), "{}");
12741    }
12742
12743    #[test]
12744    fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
12745        let state = test_json!({
12746            "keptA": 1,
12747            "keptB": 2,
12748            "serverOnlyA": 3,
12749            "serverOnlyB": 4,
12750        });
12751        let keys = ["keptA", "keptA", "keptB", "missing"];
12752        let selection = StateSelection::Keys(keys.to_vec());
12753        let mut sink = TestWriter::new();
12754        let mut scratch = Vec::new();
12755        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12756        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12757    }
12758
12759    #[test]
12760    fn write_selected_state_map_first_matches_schema_first_output() {
12761        let state = test_json!({
12762            "keptA": 1,
12763            "keptB": 2,
12764        });
12765        let keys = ["keptA", "keptB", "missingA", "missingB"];
12766        let selection = StateSelection::Keys(keys.to_vec());
12767        let mut sink = TestWriter::new();
12768        let mut scratch = Vec::new();
12769        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12770        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12771    }
12772
12773    #[test]
12774    fn write_selected_state_full_preserves_and_escapes_state() {
12775        let state = test_json!({
12776            "serverOnly": "</script><b>",
12777            "value": 42,
12778        });
12779        let mut sink = TestWriter::new();
12780        let mut scratch = Vec::new();
12781        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12782        assert_eq!(
12783            sink.get_content(),
12784            r#"{"serverOnly":"<\/script><b>","value":42}"#
12785        );
12786    }
12787
12788    #[test]
12789    fn component_asset_style_manifest_is_serialized_once_and_script_safe() {
12790        let preloads = vec![ComponentAssetStylePreload {
12791            root: "lazy-panel".to_string(),
12792            style_hrefs: vec!["/assets/</script><script>alert(1)</script>.css".to_string()],
12793        }];
12794
12795        let manifest = serialize_component_asset_style_manifest(&preloads).unwrap();
12796
12797        assert_eq!(
12798            manifest,
12799            r#"{"lazy-panel":["/assets/<\/script><script>alert(1)<\/script>.css"]}"#
12800        );
12801    }
12802
12803    #[test]
12804    fn json_scratch_capacity_reused_across_checkpoint_payloads() {
12805        // Simulates several checkpoint bootstraps serialized in one render: a
12806        // single request-local buffer must grow once for the largest payload and
12807        // then be reused for subsequent payloads without reallocating. This is
12808        // the deterministic seam behind the streaming path's allocation gate —
12809        // `write_script_safe_json` is `pub(crate)`, so no public API is exposed.
12810        let mut sink = TestWriter::new();
12811        let mut scratch: Vec<u8> = Vec::new();
12812        // No allocation until serialization actually needs it.
12813        assert_eq!(scratch.capacity(), 0);
12814
12815        // The largest payload first establishes the high-water capacity.
12816        let mut large = serde_json::Map::new();
12817        for index in 0..64 {
12818            large.insert(
12819                format!("component_{index:04}"),
12820                Value::String("x".repeat(48)),
12821            );
12822        }
12823        let large = Value::Object(large);
12824        write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
12825        let high_water = scratch.capacity();
12826        assert!(high_water > 0);
12827
12828        // Subsequent smaller checkpoint payloads reuse the same buffer: capacity
12829        // never grows (no per-checkpoint allocation) and never shrinks.
12830        for seq in 0..8 {
12831            let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
12832            write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
12833            assert_eq!(
12834                scratch.capacity(),
12835                high_water,
12836                "scratch reallocated on checkpoint {seq}"
12837            );
12838        }
12839    }
12840
12841    #[test]
12842    fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
12843        let mut fragments = HashMap::new();
12844        fragments.insert(
12845            "index.html".to_string(),
12846            FragmentList {
12847                fragments: vec![
12848                    WebUIFragment::raw("<html><body>"),
12849                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
12850                        path: "/".to_string(),
12851                        fragment_id: "home-page".to_string(),
12852                        exact: true,
12853                        ..Default::default()
12854                    }),
12855                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
12856                        path: "/admin".to_string(),
12857                        fragment_id: "admin-page".to_string(),
12858                        exact: true,
12859                        ..Default::default()
12860                    }),
12861                    structural_fragment("body_end"),
12862                    WebUIFragment::raw("</body></html>"),
12863                ],
12864
12865                contains_boundary: false,
12866            },
12867        );
12868        fragments.insert(
12869            "home-page".to_string(),
12870            FragmentList {
12871                fragments: vec![WebUIFragment::raw("<p>Home</p>")],
12872                contains_boundary: false,
12873            },
12874        );
12875        fragments.insert(
12876            "admin-page".to_string(),
12877            FragmentList {
12878                fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
12879                contains_boundary: false,
12880            },
12881        );
12882
12883        let mut protocol = WebUIProtocol::new(fragments);
12884        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12885        protocol.components.insert(
12886            "home-page".to_string(),
12887            webui_protocol::ComponentData {
12888                template_json: "{}".to_string(),
12889                hydration_mode: StateProjectionMode::Keys as i32,
12890                hydration_keys: vec!["homeTitle".to_string()],
12891                ..Default::default()
12892            },
12893        );
12894        protocol.components.insert(
12895            "admin-page".to_string(),
12896            webui_protocol::ComponentData {
12897                template_json: "{}".to_string(),
12898                hydration_mode: StateProjectionMode::Keys as i32,
12899                hydration_keys: vec!["adminToken".to_string()],
12900                ..Default::default()
12901            },
12902        );
12903        let state = test_json!({
12904            "homeTitle": "Welcome",
12905            "adminToken": "TOP_SECRET_SENTINEL",
12906        });
12907        let handler = WebUIHandler::with_plugin(|| {
12908            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12909        });
12910        let mut writer = TestWriter::new();
12911        handler.handle(
12912            &protocol,
12913            &state,
12914            &RenderOptions::new("index.html", "/"),
12915            &mut writer,
12916        )?;
12917        let output = writer.get_content();
12918        assert!(output.contains(r#""homeTitle":"Welcome""#));
12919        assert!(!output.contains("TOP_SECRET_SENTINEL"));
12920        assert!(!output.contains(r#""adminToken""#));
12921        Ok(())
12922    }
12923
12924    #[test]
12925    fn test_component_attr_name_aria() {
12926        // component_attr_name correctly maps ARIA attributes via the shared table
12927        assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
12928        assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
12929        assert_eq!(
12930            component_attr_name("aria-activedescendant"),
12931            "ariaActiveDescendant"
12932        );
12933        assert_eq!(component_attr_name("aria-label"), "ariaLabel");
12934        assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
12935    }
12936
12937    #[test]
12938    fn test_component_attr_name_html_global() {
12939        assert_eq!(component_attr_name("readonly"), "readOnly");
12940        assert_eq!(component_attr_name("tabindex"), "tabIndex");
12941        assert_eq!(component_attr_name("accesskey"), "accessKey");
12942        assert_eq!(component_attr_name("contenteditable"), "contentEditable");
12943        assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
12944        assert_eq!(component_attr_name("inputmode"), "inputMode");
12945        assert_eq!(component_attr_name("maxlength"), "maxLength");
12946        assert_eq!(component_attr_name("minlength"), "minLength");
12947        assert_eq!(component_attr_name("novalidate"), "noValidate");
12948        assert_eq!(component_attr_name("formaction"), "formAction");
12949        assert_eq!(component_attr_name("ismap"), "isMap");
12950        assert_eq!(component_attr_name("usemap"), "useMap");
12951    }
12952
12953    #[test]
12954    fn test_component_attr_name_strips_colon() {
12955        assert_eq!(component_attr_name(":readonly"), "readOnly");
12956        assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
12957        assert_eq!(component_attr_name(":data-title"), "dataTitle");
12958    }
12959
12960    #[test]
12961    fn test_component_attr_name_regular() {
12962        assert_eq!(component_attr_name("data-title"), "dataTitle");
12963        assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
12964        assert_eq!(component_attr_name("simple"), "simple");
12965    }
12966
12967    // ── allowed_query SSR emission tests ─────────────────────────────
12968
12969    fn make_query_route_protocol() -> WebUIProtocol {
12970        use webui_protocol::WebUiFragmentRoute;
12971
12972        let mut fragments = HashMap::new();
12973
12974        fragments.insert(
12975            "index.html".to_string(),
12976            FragmentList {
12977                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
12978                    path: "/".into(),
12979                    fragment_id: "app-shell".into(),
12980                    exact: false,
12981                    children: vec![
12982                        WebUiFragmentRoute {
12983                            path: "compose".into(),
12984                            fragment_id: "compose-page".into(),
12985                            exact: true,
12986                            allowed_query: "action,to,subject".into(),
12987                            keep_alive: false,
12988                            ..Default::default()
12989                        },
12990                        WebUiFragmentRoute {
12991                            path: "settings".into(),
12992                            fragment_id: "settings-page".into(),
12993                            exact: true,
12994                            keep_alive: false,
12995                            ..Default::default()
12996                        },
12997                    ],
12998                    keep_alive: false,
12999                    ..Default::default()
13000                })],
13001
13002                contains_boundary: false,
13003            },
13004        );
13005
13006        fragments.insert(
13007            "app-shell".to_string(),
13008            FragmentList {
13009                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
13010                contains_boundary: false,
13011            },
13012        );
13013        fragments.insert(
13014            "compose-page".to_string(),
13015            FragmentList {
13016                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
13017                contains_boundary: false,
13018            },
13019        );
13020        fragments.insert(
13021            "settings-page".to_string(),
13022            FragmentList {
13023                fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
13024                contains_boundary: false,
13025            },
13026        );
13027
13028        WebUIProtocol::new(fragments)
13029    }
13030
13031    #[test]
13032    fn test_matched_route_omits_query_attr_from_dom() {
13033        let protocol = make_query_route_protocol();
13034        let state = test_json!({});
13035        let handler = WebUIHandler::new();
13036        let mut writer = TestWriter::new();
13037
13038        handler
13039            .handle(
13040                &protocol,
13041                &state,
13042                &RenderOptions::new("index.html", "/compose"),
13043                &mut writer,
13044            )
13045            .expect("render failed");
13046
13047        let html = writer.get_content();
13048        // query attr is no longer in DOM — it's in the SSR chain JSON instead
13049        assert!(
13050            !html.contains(r#"query="action,to,subject""#),
13051            "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
13052        );
13053    }
13054
13055    #[test]
13056    fn test_nonmatched_route_omits_query_attr_from_dom() {
13057        let protocol = make_query_route_protocol();
13058        let state = test_json!({});
13059        let handler = WebUIHandler::new();
13060        let mut writer = TestWriter::new();
13061
13062        handler
13063            .handle(
13064                &protocol,
13065                &state,
13066                &RenderOptions::new("index.html", "/settings"),
13067                &mut writer,
13068            )
13069            .expect("render failed");
13070
13071        let html = writer.get_content();
13072        // query attr should not be on hidden siblings either
13073        assert!(
13074            !html.contains(r#"query="#),
13075            "hidden route should not have query attr: {html}"
13076        );
13077    }
13078
13079    #[test]
13080    fn test_route_without_query_has_no_query_attr() {
13081        let protocol = make_query_route_protocol();
13082        let state = test_json!({});
13083        let handler = WebUIHandler::new();
13084        let mut writer = TestWriter::new();
13085
13086        handler
13087            .handle(
13088                &protocol,
13089                &state,
13090                &RenderOptions::new("index.html", "/settings"),
13091                &mut writer,
13092            )
13093            .expect("render failed");
13094
13095        let html = writer.get_content();
13096        // Find the settings route element and verify it has no query attr
13097        let settings_idx = html
13098            .find(r#"component="settings-page""#)
13099            .expect("settings route should exist");
13100        let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
13101        assert!(
13102            !settings_tag.contains("query="),
13103            "route without allowed_query should not emit query attr: {settings_tag}"
13104        );
13105    }
13106
13107    // ── Per-render head_inject / body_inject (replaces the byte-scanner
13108    //    InjectingStreamingWriter approach with structural signal-based
13109    //    injection) ───────────────────────────────────────────────────
13110
13111    fn build_head_body_protocol() -> WebUIProtocol {
13112        let mut fragments = HashMap::new();
13113        fragments.insert(
13114            "index.html".to_string(),
13115            FragmentList {
13116                fragments: vec![
13117                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13118                    structural_fragment("head_end"),
13119                    WebUIFragment::raw("</head><body>hello".to_string()),
13120                    structural_fragment("body_end"),
13121                    WebUIFragment::raw("</body></html>".to_string()),
13122                ],
13123                contains_boundary: false,
13124            },
13125        );
13126        WebUIProtocol::new(fragments)
13127    }
13128
13129    #[test]
13130    fn head_inject_emits_at_head_end_boundary() {
13131        let protocol = build_head_body_protocol();
13132        let state = test_json!({});
13133        let mut writer = TestWriter::new();
13134        let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
13135        handle(&protocol, &state, &opts, &mut writer).unwrap();
13136        let html = writer.get_content();
13137        // The inject must appear immediately before `</head>`.
13138        let inject_idx = html
13139            .find("<link rel=preload>")
13140            .expect("inject HTML missing");
13141        let head_close_idx = html.find("</head>").expect("</head> missing");
13142        assert!(
13143            inject_idx < head_close_idx,
13144            "head_inject must appear before </head>: {html}"
13145        );
13146        // No duplicate.
13147        assert_eq!(html.matches("<link rel=preload>").count(), 1);
13148    }
13149
13150    #[test]
13151    fn component_render_policy_css_emits_once_in_head_with_nonce() {
13152        let mut protocol = build_head_body_protocol();
13153        protocol.component_render_css = concat!(
13154            r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
13155            "contain-intrinsic-block-size:auto 18rem;}"
13156        )
13157        .to_string();
13158        let state = test_json!({});
13159        let mut writer = TestWriter::new();
13160        let opts = RenderOptions::new("index.html", "/").with_nonce("test-nonce");
13161        handle(&protocol, &state, &opts, &mut writer).unwrap();
13162        let html = writer.get_content();
13163
13164        let style = concat!(
13165            r#"<style data-webui-render-policy nonce="test-nonce">"#,
13166            r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
13167            "contain-intrinsic-block-size:auto 18rem;}</style>"
13168        );
13169        let style_index = html.find(style).expect("render policy style missing");
13170        let head_close = html.find("</head>").expect("</head> missing");
13171        assert!(style_index < head_close);
13172        assert_eq!(html.matches("data-webui-render-policy").count(), 1);
13173    }
13174
13175    #[test]
13176    fn body_inject_emits_at_body_end_boundary() {
13177        let protocol = build_head_body_protocol();
13178        let state = test_json!({});
13179        let mut writer = TestWriter::new();
13180        let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
13181        handle(&protocol, &state, &opts, &mut writer).unwrap();
13182        let html = writer.get_content();
13183        let inject_idx = html
13184            .find("<script>lr</script>")
13185            .expect("inject HTML missing");
13186        let body_close_idx = html.find("</body>").expect("</body> missing");
13187        assert!(
13188            inject_idx < body_close_idx,
13189            "body_inject must appear before </body>: {html}"
13190        );
13191        assert_eq!(html.matches("<script>lr</script>").count(), 1);
13192    }
13193
13194    // ── Reserved `$webui` state inject namespace ──────────────────────
13195
13196    /// Protocol carrying all three structural boundaries, so `body_start`
13197    /// placement can be asserted alongside `head_end` / `body_end`.
13198    fn build_all_boundaries_protocol() -> WebUIProtocol {
13199        let mut fragments = HashMap::new();
13200        fragments.insert(
13201            "index.html".to_string(),
13202            FragmentList {
13203                fragments: vec![
13204                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13205                    structural_fragment("head_end"),
13206                    WebUIFragment::raw("</head><body>".to_string()),
13207                    structural_fragment("body_start"),
13208                    WebUIFragment::raw("hello".to_string()),
13209                    structural_fragment("body_end"),
13210                    WebUIFragment::raw("</body></html>".to_string()),
13211                ],
13212                contains_boundary: false,
13213            },
13214        );
13215        WebUIProtocol::new(fragments)
13216    }
13217
13218    fn state_inject_options<'a>() -> RenderOptions<'a> {
13219        RenderOptions::new("index.html", "/")
13220    }
13221
13222    fn render_with(protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>) -> String {
13223        let mut writer = TestWriter::new();
13224        handle(protocol, state, options, &mut writer).unwrap();
13225        writer.get_content().to_string()
13226    }
13227
13228    #[test]
13229    fn state_inject_emits_at_every_structural_boundary() {
13230        let protocol = build_all_boundaries_protocol();
13231        let state = test_json!({
13232            "$webui": {
13233                "headEnd": "<meta name=he>",
13234                "bodyStart": "<span id=bs></span>",
13235                "bodyEnd": "<script>be</script>",
13236            }
13237        });
13238        let html = render_with(&protocol, &state, &state_inject_options());
13239
13240        let head_end = html.find("<meta name=he>").expect("headEnd missing");
13241        let head_close = html.find("</head>").expect("</head> missing");
13242        let body_open = html.find("<body>").expect("<body> missing");
13243        let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
13244        let hello = html.find("hello").expect("body content missing");
13245        let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
13246        let body_close = html.find("</body>").expect("</body> missing");
13247
13248        assert!(
13249            head_end < head_close,
13250            "headEnd must precede </head>: {html}"
13251        );
13252        assert!(
13253            body_open < body_start && body_start < hello,
13254            "bodyStart must sit immediately after <body>: {html}"
13255        );
13256        assert!(
13257            hello < body_end && body_end < body_close,
13258            "bodyEnd must precede </body>: {html}"
13259        );
13260
13261        for needle in [
13262            "<meta name=he>",
13263            "<span id=bs></span>",
13264            "<script>be</script>",
13265        ] {
13266            assert_eq!(html.matches(needle).count(), 1, "duplicated {needle}");
13267        }
13268    }
13269
13270    #[test]
13271    fn state_inject_follows_render_options_inject() {
13272        let protocol = build_all_boundaries_protocol();
13273        let state = test_json!({
13274            "$webui": { "headEnd": "<!--state-he-->", "bodyEnd": "<!--state-be-->" }
13275        });
13276        let options = RenderOptions::new("index.html", "/")
13277            .with_head_inject("<!--opt-he-->")
13278            .with_body_inject("<!--opt-be-->");
13279        let html = render_with(&protocol, &state, &options);
13280
13281        let opt_he = html.find("<!--opt-he-->").expect("option headEnd missing");
13282        let state_he = html.find("<!--state-he-->").expect("state headEnd missing");
13283        let opt_be = html.find("<!--opt-be-->").expect("option bodyEnd missing");
13284        let state_be = html.find("<!--state-be-->").expect("state bodyEnd missing");
13285
13286        assert!(
13287            opt_he < state_he,
13288            "RenderOptions head_inject must precede the state-supplied value: {html}"
13289        );
13290        assert!(
13291            opt_be < state_be,
13292            "RenderOptions body_inject must precede the state-supplied value: {html}"
13293        );
13294    }
13295
13296    #[test]
13297    fn malformed_state_inject_values_are_inert() {
13298        let protocol = build_all_boundaries_protocol();
13299        // Absent key, wrong container type, and per-member null / empty /
13300        // non-string values must all render without output and without error.
13301        for state in [
13302            test_json!({}),
13303            test_json!({ "$webui": "not-an-object" }),
13304            test_json!({ "$webui": [] }),
13305            test_json!({ "$webui": null }),
13306            test_json!({ "$webui": { "headEnd": null, "bodyStart": "", "bodyEnd": 42 } }),
13307            test_json!({ "$webui": { "unknownMember": "<b>x</b>" } }),
13308        ] {
13309            let html = render_with(&protocol, &state, &state_inject_options());
13310            assert!(
13311                html.contains("<html><head><title>x</title></head><body>hello</body></html>"),
13312                "malformed reserved state must render the document unchanged: {html}"
13313            );
13314            assert!(!html.contains("<b>x</b>"), "unknown member leaked: {html}");
13315            assert!(!html.contains("42"), "non-string member leaked: {html}");
13316        }
13317    }
13318
13319    #[test]
13320    fn state_inject_never_reaches_the_hydration_payload() {
13321        let protocol = build_all_boundaries_protocol();
13322        let state = test_json!({
13323            "visible": "keep",
13324            "$webui": { "bodyEnd": "<script>secret</script>" }
13325        });
13326        let handler = WebUIHandler::with_plugin(|| {
13327            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13328        });
13329        let runtime = Protocol::new(protocol.clone());
13330        let mut writer = TestWriter::new();
13331        handler
13332            .render(&runtime, &state, &state_inject_options(), &mut writer)
13333            .unwrap();
13334        let html = writer.get_content();
13335
13336        let data_start = html
13337            .find(r#"<script type="application/json" id="webui-data""#)
13338            .expect("hydration block missing");
13339        let data_end = html[data_start..]
13340            .find("</script>")
13341            .map(|offset| data_start + offset)
13342            .expect("hydration block never closes");
13343        let payload = &html[data_start..data_end];
13344        assert!(
13345            !payload.contains("$webui"),
13346            "reserved key must be stripped from the hydration payload: {payload}"
13347        );
13348        assert!(
13349            payload.contains("visible"),
13350            "ordinary state must survive the filter: {payload}"
13351        );
13352        // Per the documented precedence the injected HTML is emitted after
13353        // the built-in hydration block, and still before `</body>`.
13354        let inject = html
13355            .find("<script>secret</script>")
13356            .expect("inject missing");
13357        let body_close = html.find("</body>").expect("</body> missing");
13358        assert!(
13359            data_end < inject && inject < body_close,
13360            "state bodyEnd must follow the hydration block and precede </body>: {html}"
13361        );
13362    }
13363
13364    #[test]
13365    fn projected_hydration_strips_reserved_state_inject_key() {
13366        let mut fragments = HashMap::new();
13367        fragments.insert(
13368            "index.html".to_string(),
13369            FragmentList {
13370                fragments: vec![
13371                    WebUIFragment::raw("<html><body>"),
13372                    WebUIFragment::component("app-shell"),
13373                    structural_fragment("body_end"),
13374                    WebUIFragment::raw("</body></html>"),
13375                ],
13376                contains_boundary: false,
13377            },
13378        );
13379        fragments.insert(
13380            "app-shell".to_string(),
13381            FragmentList {
13382                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
13383                contains_boundary: false,
13384            },
13385        );
13386        let mut document = WebUIProtocol::new(fragments);
13387        document.initial_state_strategy = InitialStateStrategy::Components as i32;
13388        document.components.insert(
13389            "app-shell".to_string(),
13390            webui_protocol::ComponentData {
13391                hydration_mode: StateProjectionMode::Keys as i32,
13392                hydration_keys: vec![STATE_INJECT_KEY.to_string(), "visible".to_string()],
13393                ..Default::default()
13394            },
13395        );
13396        let state = test_json!({
13397            "$webui": { "bodyEnd": "<script>secret</script>" },
13398            "serverOnly": "drop",
13399            "visible": "keep",
13400        });
13401        let handler = WebUIHandler::with_plugin(|| {
13402            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13403        });
13404        let mut writer = TestWriter::new();
13405
13406        handler
13407            .render(
13408                &Protocol::new(document),
13409                &state,
13410                &RenderOptions::new("index.html", "/"),
13411                &mut writer,
13412            )
13413            .unwrap();
13414        let html = writer.get_content();
13415        let data_start = html
13416            .find(r#"<script type="application/json" id="webui-data""#)
13417            .expect("hydration block missing");
13418        let data_end = html[data_start..]
13419            .find("</script>")
13420            .map(|offset| data_start + offset)
13421            .expect("hydration block never closes");
13422        let payload = &html[data_start..data_end];
13423
13424        assert!(
13425            !payload.contains(STATE_INJECT_KEY),
13426            "reserved key leaked: {payload}"
13427        );
13428        assert!(payload.contains(r#""visible":"keep""#), "{payload}");
13429        assert!(!payload.contains("serverOnly"), "{payload}");
13430        assert!(html.contains("<script>secret</script>"), "{html}");
13431    }
13432
13433    #[test]
13434    fn write_selected_state_strips_reserved_key_from_full_state() {
13435        let state = test_json!({ "a": 1, "$webui": { "bodyEnd": "<b>x</b>" }, "z": 2 });
13436        let mut sink = TestWriter::new();
13437        let mut scratch = Vec::new();
13438        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13439        let json = sink.get_content();
13440        assert!(!json.contains("$webui"), "reserved key leaked: {json}");
13441        assert!(
13442            json.contains("\"a\":1") && json.contains("\"z\":2"),
13443            "{json}"
13444        );
13445    }
13446
13447    #[test]
13448    fn write_selected_state_strips_reserved_key_from_key_id_projection() {
13449        // The streaming record projects through interned key IDs, so the
13450        // reserved inject key must be filtered on that path too.
13451        let mut protocol = WebUIProtocol::new(HashMap::new());
13452        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13453        protocol.fragments.insert(
13454            "keep-card".to_string(),
13455            webui_protocol::FragmentList::default(),
13456        );
13457        protocol.components.insert(
13458            "keep-card".to_string(),
13459            webui_protocol::ComponentData {
13460                hydration_mode: StateProjectionMode::Keys as i32,
13461                hydration_keys: vec![STATE_INJECT_KEY.to_string(), "keep".to_string()],
13462                ..Default::default()
13463            },
13464        );
13465        let protocol = Protocol::new(protocol);
13466        let index = protocol.component_reachability();
13467        let component = protocol.component_index()["keep-card"];
13468        let mut ids = Vec::new();
13469        assert!(!collect_hydration_key_ids_into(
13470            protocol.protocol(),
13471            index,
13472            [component],
13473            &mut ids
13474        ));
13475
13476        let state = test_json!({
13477            "$webui": { "bodyEnd": "<b>x</b>" },
13478            "keep": 1,
13479        });
13480        let mut sink = TestWriter::new();
13481        let mut scratch = Vec::new();
13482
13483        write_selected_state(
13484            &mut sink,
13485            &mut scratch,
13486            &state,
13487            &StateSelection::KeyIds(HydrationKeySelection { ids: &ids, index }),
13488        )
13489        .unwrap();
13490
13491        assert_eq!(sink.get_content(), r#"{"keep":1}"#);
13492    }
13493
13494    #[test]
13495    fn write_selected_state_full_is_unchanged_without_reserved_key() {
13496        let state = test_json!({ "a": 1, "z": 2 });
13497        let mut sink = TestWriter::new();
13498        let mut scratch = Vec::new();
13499        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13500        assert_eq!(sink.get_content(), r#"{"a":1,"z":2}"#);
13501    }
13502
13503    #[test]
13504    fn body_start_hook_dedupes_on_malformed_protocol() {
13505        let mut fragments = HashMap::new();
13506        fragments.insert(
13507            "index.html".to_string(),
13508            FragmentList {
13509                fragments: vec![
13510                    WebUIFragment::raw("<html><head></head><body>".to_string()),
13511                    structural_fragment("body_start"),
13512                    structural_fragment("body_start"),
13513                    WebUIFragment::raw("</body></html>".to_string()),
13514                ],
13515                contains_boundary: false,
13516            },
13517        );
13518        let protocol = WebUIProtocol::new(fragments);
13519        let state = test_json!({ "$webui": { "bodyStart": "<i>once</i>" } });
13520        let html = render_with(&protocol, &state, &state_inject_options());
13521        assert_eq!(
13522            html.matches("<i>once</i>").count(),
13523            1,
13524            "duplicate body_start signals must not duplicate the inject: {html}"
13525        );
13526    }
13527
13528    #[test]
13529    fn injects_are_no_op_when_unset() {
13530        let protocol = build_head_body_protocol();
13531        let state = test_json!({});
13532        let mut writer = TestWriter::new();
13533        handle(
13534            &protocol,
13535            &state,
13536            &RenderOptions::new("index.html", "/"),
13537            &mut writer,
13538        )
13539        .unwrap();
13540        let html = writer.get_content();
13541        assert!(!html.contains("<link rel=preload>"));
13542        assert!(!html.contains("<script>lr</script>"));
13543    }
13544
13545    #[test]
13546    fn empty_inject_string_treated_as_unset() {
13547        let protocol = build_head_body_protocol();
13548        let state = test_json!({});
13549        let mut writer = TestWriter::new();
13550        let opts = RenderOptions::new("index.html", "/")
13551            .with_head_inject("")
13552            .with_body_inject("");
13553        handle(&protocol, &state, &opts, &mut writer).unwrap();
13554        // No injection happens — empty strings are normalised to None
13555        // by the builder, so the output is identical to the no-options case.
13556        let html = writer.get_content();
13557        assert!(html.contains("</head>"));
13558        assert!(html.contains("</body>"));
13559    }
13560
13561    #[test]
13562    fn inject_html_is_passed_through_verbatim() {
13563        // The handler does NOT escape the inject string — hosts pass
13564        // raw HTML they trust. This test pins that contract: a `<` in
13565        // the inject is emitted as-is, not encoded as `&lt;`.
13566        let protocol = build_head_body_protocol();
13567        let state = test_json!({});
13568        let mut writer = TestWriter::new();
13569        let opts =
13570            RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
13571        handle(&protocol, &state, &opts, &mut writer).unwrap();
13572        assert!(writer.get_content().contains("<script>var x=1;</script>"));
13573    }
13574
13575    /// Both injects fire and appear at the correct structural
13576    /// positions. Critically, this is robust against `</head>` /
13577    /// `</body>` literals appearing elsewhere in the document — the
13578    /// signal-based emitter cannot mis-fire on byte patterns inside
13579    /// HTML comments, `<iframe srcdoc>`, or inline scripts (which the
13580    /// previous byte-scanner could).
13581    #[test]
13582    fn injects_robust_against_marker_literals_in_content() {
13583        let mut fragments = HashMap::new();
13584        // The body intentionally contains `</body>` and `</head>`
13585        // literals before the actual structural close — these came
13586        // from a (hypothetical) iframe srcdoc or comment.
13587        fragments.insert(
13588            "index.html".to_string(),
13589            FragmentList {
13590                fragments: vec![
13591                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13592                    structural_fragment("head_end"),
13593                    WebUIFragment::raw(
13594                        "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
13595                    ),
13596                    structural_fragment("body_end"),
13597                    WebUIFragment::raw("</body></html>".to_string()),
13598                ],
13599                contains_boundary: false,
13600            },
13601        );
13602        let protocol = WebUIProtocol::new(fragments);
13603        let state = test_json!({});
13604        let mut writer = TestWriter::new();
13605        let opts = RenderOptions::new("index.html", "/")
13606            .with_head_inject("<HEAD-INJ>")
13607            .with_body_inject("<BODY-INJ>");
13608        handle(&protocol, &state, &opts, &mut writer).unwrap();
13609        let html = writer.get_content();
13610        // The head inject sits between `<title>x</title>` and the
13611        // first `</head>` — the structural one, not the comment one.
13612        let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
13613        let head_close_idx = html.find("</head>").expect("</head> missing");
13614        assert!(head_inj_idx < head_close_idx);
13615        // The body inject sits before the structural `</body>` — NOT
13616        // before the `</body>` literal in the comment (which would
13617        // require the inject to appear inside `<p>hi</p>` somewhere).
13618        let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
13619        // Find the LAST `</body>` (the structural one).
13620        let body_close_idx = html.rfind("</body>").expect("</body> missing");
13621        assert!(
13622            body_inj_idx < body_close_idx,
13623            "body_inject must precede the structural </body>: {html}"
13624        );
13625        // And the comment is preserved verbatim.
13626        assert!(html.contains("<!-- </body> </head> -->"));
13627    }
13628
13629    /// Coverage-14: both `head_inject` AND `body_inject` set in the
13630    /// same render. Each fires at the correct structural boundary and
13631    /// neither leaks into the other's region.
13632    #[test]
13633    fn both_injects_fire_at_correct_boundaries() {
13634        let protocol = build_head_body_protocol();
13635        let state = test_json!({});
13636        let mut writer = TestWriter::new();
13637        let opts = RenderOptions::new("index.html", "/")
13638            .with_head_inject("<META-HEAD>")
13639            .with_body_inject("<SCRIPT-BODY>");
13640        handle(&protocol, &state, &opts, &mut writer).unwrap();
13641        let html = writer.get_content();
13642        let head_idx = html.find("<META-HEAD>").expect("head inject missing");
13643        let head_close = html.find("</head>").expect("</head> missing");
13644        let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
13645        let body_close = html.find("</body>").expect("</body> missing");
13646        assert!(head_idx < head_close, "head_inject before </head>");
13647        assert!(head_close < body_idx, "body_inject after </head>");
13648        assert!(body_idx < body_close, "body_inject before </body>");
13649        assert_eq!(html.matches("<META-HEAD>").count(), 1);
13650        assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
13651    }
13652
13653    /// Coverage-15 / Bug-3 (security defense): a malformed protocol
13654    /// emitting `head_end` and `body_end` more than once must NOT
13655    /// duplicate the host inject HTML. Without the dedup guard,
13656    /// double-emission would amplify Security-2 (a 1 MiB inject ×
13657    /// 1000 duplicate signals = 1 GiB output).
13658    #[test]
13659    fn injects_dedupe_against_duplicate_signals() {
13660        let mut fragments = HashMap::new();
13661        fragments.insert(
13662            "index.html".to_string(),
13663            FragmentList {
13664                fragments: vec![
13665                    WebUIFragment::raw("<html><head>".to_string()),
13666                    structural_fragment("head_end"),
13667                    structural_fragment("head_end"), // duplicate
13668                    structural_fragment("head_end"), // triplicate
13669                    WebUIFragment::raw("</head><body>".to_string()),
13670                    structural_fragment("body_end"),
13671                    structural_fragment("body_end"), // duplicate
13672                    WebUIFragment::raw("</body></html>".to_string()),
13673                ],
13674                contains_boundary: false,
13675            },
13676        );
13677        let protocol = WebUIProtocol::new(fragments);
13678        let state = test_json!({});
13679        let mut writer = TestWriter::new();
13680        let opts = RenderOptions::new("index.html", "/")
13681            .with_head_inject("<HINJ>")
13682            .with_body_inject("<BINJ>");
13683        handle(&protocol, &state, &opts, &mut writer).unwrap();
13684        let html = writer.get_content();
13685        assert_eq!(
13686            html.matches("<HINJ>").count(),
13687            1,
13688            "head_inject must emit exactly once even with duplicate head_end signals"
13689        );
13690        assert_eq!(
13691            html.matches("<BINJ>").count(),
13692            1,
13693            "body_inject must emit exactly once even with duplicate body_end signals"
13694        );
13695    }
13696
13697    /// Coverage-15: a Shadow-DOM / component-only protocol that has NO
13698    /// `<head>` / `<body>` tags must NOT emit the inject (the signals
13699    /// never fire). Verifies the injects are no-ops, not panics.
13700    #[test]
13701    fn injects_no_op_when_no_head_or_body_signals() {
13702        let mut fragments = HashMap::new();
13703        fragments.insert(
13704            "index.html".to_string(),
13705            FragmentList {
13706                fragments: vec![WebUIFragment::raw(
13707                    "<my-component>hi</my-component>".to_string(),
13708                )],
13709                contains_boundary: false,
13710            },
13711        );
13712        let protocol = WebUIProtocol::new(fragments);
13713        let state = test_json!({});
13714        let mut writer = TestWriter::new();
13715        let opts = RenderOptions::new("index.html", "/")
13716            .with_head_inject("<HINJ>")
13717            .with_body_inject("<BINJ>");
13718        handle(&protocol, &state, &opts, &mut writer).unwrap();
13719        let html = writer.get_content();
13720        assert!(!html.contains("<HINJ>"), "head_inject must not appear");
13721        assert!(!html.contains("<BINJ>"), "body_inject must not appear");
13722        assert!(html.contains("<my-component>"));
13723    }
13724
13725    /// Coverage-19: the handler's `&self` is shared across threads.
13726    /// Two concurrent renders with different inject values must NOT
13727    /// cross-contaminate (each thread sees only its own inject).
13728    /// Per-render mutable state lives on the `WebUIProcessContext`,
13729    /// which is stack-allocated per call.
13730    #[test]
13731    fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
13732        let protocol = std::sync::Arc::new(build_head_body_protocol());
13733        let state = std::sync::Arc::new(test_json!({}));
13734        let handler = std::sync::Arc::new(WebUIHandler::new());
13735
13736        const N_THREADS: usize = 16;
13737        let mut handles = Vec::with_capacity(N_THREADS);
13738        for tid in 0..N_THREADS {
13739            let h = std::sync::Arc::clone(&handler);
13740            let p = std::sync::Arc::clone(&protocol);
13741            let s = std::sync::Arc::clone(&state);
13742            handles.push(std::thread::spawn(move || {
13743                let head = format!("<HEAD-T{tid}>");
13744                let body = format!("<BODY-T{tid}>");
13745                let mut writer = TestWriter::new();
13746                let opts = RenderOptions::new("index.html", "/")
13747                    .with_head_inject(&head)
13748                    .with_body_inject(&body);
13749                h.handle(&p, &s, &opts, &mut writer).unwrap();
13750                let html = writer.get_content();
13751                // Must contain my own injects exactly once.
13752                assert_eq!(html.matches(&head).count(), 1);
13753                assert_eq!(html.matches(&body).count(), 1);
13754                // Must NOT contain any other thread's inject.
13755                for other in 0..N_THREADS {
13756                    if other == tid {
13757                        continue;
13758                    }
13759                    let other_head = format!("<HEAD-T{other}>");
13760                    let other_body = format!("<BODY-T{other}>");
13761                    assert!(
13762                        !html.contains(&other_head),
13763                        "tid {tid} saw {other}'s head_inject"
13764                    );
13765                    assert!(
13766                        !html.contains(&other_body),
13767                        "tid {tid} saw {other}'s body_inject"
13768                    );
13769                }
13770            }));
13771        }
13772        for h in handles {
13773            h.join().expect("worker panicked");
13774        }
13775    }
13776
13777    /// Coverage-17: a large (1 MiB) head_inject must round-trip
13778    /// correctly without panic, truncation, or excessive overhead.
13779    /// (No size cap is enforced by the handler — the host owns the
13780    /// safety contract; see `with_head_inject` doc comment.)
13781    #[test]
13782    fn large_inject_roundtrips_without_truncation() {
13783        let protocol = build_head_body_protocol();
13784        let state = test_json!({});
13785        let mut writer = TestWriter::new();
13786        let big = "x".repeat(1024 * 1024);
13787        let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
13788        handle(&protocol, &state, &opts, &mut writer).unwrap();
13789        let html = writer.get_content();
13790        assert!(
13791            html.contains(&big),
13792            "large head_inject must be present verbatim ({} bytes)",
13793            big.len()
13794        );
13795        // Sanity: only one copy.
13796        assert_eq!(html.matches(&big).count(), 1);
13797    }
13798
13799    /// `with_nonce("")` must normalize to `None` (no `<meta>` emitted),
13800    /// matching the empty-string semantics of `with_head_inject` /
13801    /// `with_body_inject`. An empty content attribute is browser-
13802    /// ignored noise.
13803    #[test]
13804    fn empty_nonce_treated_as_unset() {
13805        let protocol = build_head_body_protocol();
13806        let state = test_json!({});
13807        let mut writer = TestWriter::new();
13808        let opts = RenderOptions::new("index.html", "/").with_nonce("");
13809        handle(&protocol, &state, &opts, &mut writer).unwrap();
13810        assert!(
13811            !writer.get_content().contains("webui-nonce"),
13812            "empty nonce must not emit <meta name=\"webui-nonce\">"
13813        );
13814    }
13815
13816    /// Regression for the bug Akrosh caught: the `pub` fields on
13817    /// `RenderOptions` let a caller bypass the `with_*` builder
13818    /// normalisation, e.g.:
13819    ///
13820    /// ```ignore
13821    /// RenderOptions { nonce: Some(""), ..RenderOptions::new(e, p) }
13822    /// ```
13823    ///
13824    /// Without defensive normalisation at handler init, this would
13825    /// emit `<script nonce="">` on every inline script. Under a
13826    /// strict `Content-Security-Policy: script-src 'nonce-...'` an
13827    /// empty nonce is a HARD CSP failure that blocks every inline
13828    /// script — a complete inline-script-execution outage.
13829    ///
13830    /// The handler now treats `Some("")` identically to `None` for
13831    /// all three injection points (nonce / head_inject / body_inject)
13832    /// regardless of how the option was populated.
13833    #[test]
13834    fn empty_field_bypass_is_normalised_at_handler_init() {
13835        let protocol = build_head_body_protocol();
13836        let state = test_json!({});
13837
13838        // Bypass the `with_nonce` builder by writing the field directly.
13839        let opts_with_empty_nonce = RenderOptions {
13840            nonce: Some(""),
13841            ..RenderOptions::new("index.html", "/")
13842        };
13843        let mut writer = TestWriter::new();
13844        handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
13845        let html = writer.get_content();
13846        assert!(
13847            !html.contains("webui-nonce"),
13848            "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
13849        );
13850        assert!(
13851            !html.contains("nonce=\"\""),
13852            "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
13853        );
13854
13855        // Same defence for inject fields.
13856        let opts_with_empty_injects = RenderOptions {
13857            head_inject: Some(""),
13858            body_inject: Some(""),
13859            ..RenderOptions::new("index.html", "/")
13860        };
13861        let mut writer = TestWriter::new();
13862        handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
13863        // No assertion needed beyond "doesn't panic and doesn't emit
13864        // empty inject markers" — the head_end / body_end paths must
13865        // treat the empty inject as no-op the same way the builder does.
13866    }
13867
13868    /// Regression for the deep-audit's Bug-6 claim. The for-loop hot-
13869    /// path optimisation (insert key once + `get_mut`-swap value
13870    /// in-place) was suspected of corrupting the outer scope when a
13871    /// nested `<for>` loop reuses the same variable name. This test
13872    /// proves the optimisation is correct under that condition by
13873    /// requiring the outer `item` to be visible before, between, and
13874    /// after the inner loop, with its value preserved across inner
13875    /// iterations.
13876    ///
13877    /// Trace through the optimisation on `outer = [A, B]`,
13878    /// `inner = [X, Y]` with both loops using `item` as the variable:
13879    ///
13880    ///   outer pre-insert "item": Null
13881    ///   iter 1: get_mut → write A
13882    ///     emit "outer:A"
13883    ///     enter inner: saved = remove("item") = Some(A)
13884    ///                  pre-insert "item": Null
13885    ///                  iter 1: write X → emit "inner:X"
13886    ///                  iter 2: write Y → emit "inner:Y"
13887    ///                  restore: insert("item", A)   ← outer's A back
13888    ///     emit "outer:A again"               ← reads A correctly
13889    ///   iter 2: get_mut → write B (overwrites the restored A,
13890    ///                              but that's correct — we're now
13891    ///                              in iter 2 of the outer loop)
13892    ///     emit "outer:B"
13893    ///     enter inner: saved = remove("item") = Some(B), …, restore B
13894    ///     emit "outer:B again"
13895    ///
13896    /// If the audit's claim were correct — that the outer's `get_mut`
13897    /// somehow held a reference past the inner loop and clobbered the
13898    /// restoration — we'd see corrupted values in the "outer:X again"
13899    /// emissions. The assertion below pins the correct sequence.
13900    #[test]
13901    fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
13902        let mut fragments = HashMap::new();
13903        fragments.insert(
13904            "index.html".to_string(),
13905            FragmentList {
13906                fragments: vec![
13907                    WebUIFragment::raw("["),
13908                    WebUIFragment::for_loop("item", "outer", "outer_body"),
13909                    WebUIFragment::raw("]"),
13910                ],
13911                contains_boundary: false,
13912            },
13913        );
13914        fragments.insert(
13915            "outer_body".to_string(),
13916            FragmentList {
13917                fragments: vec![
13918                    WebUIFragment::raw("(O="),
13919                    WebUIFragment::signal("item.tag", false),
13920                    WebUIFragment::for_loop("item", "inner", "inner_body"),
13921                    WebUIFragment::raw(",O="),
13922                    WebUIFragment::signal("item.tag", false),
13923                    WebUIFragment::raw(")"),
13924                ],
13925                contains_boundary: false,
13926            },
13927        );
13928        fragments.insert(
13929            "inner_body".to_string(),
13930            FragmentList {
13931                fragments: vec![
13932                    WebUIFragment::raw("[I="),
13933                    WebUIFragment::signal("item.tag", false),
13934                    WebUIFragment::raw("]"),
13935                ],
13936                contains_boundary: false,
13937            },
13938        );
13939        let protocol = WebUIProtocol::new(fragments);
13940        let state = test_json!({
13941            "outer": [{"tag": "A"}, {"tag": "B"}],
13942            "inner": [{"tag": "X"}, {"tag": "Y"}],
13943        });
13944        let mut writer = TestWriter::new();
13945        handle(
13946            &protocol,
13947            &state,
13948            &RenderOptions::new("index.html", "/"),
13949            &mut writer,
13950        )
13951        .unwrap();
13952        // Expected sequence:
13953        //   outer iter 1 (item=A):
13954        //     emit "(O=A"               ← outer A before inner
13955        //     inner iter 1 (item=X) emit "[I=X]"
13956        //     inner iter 2 (item=Y) emit "[I=Y]"
13957        //     emit ",O=A)"              ← outer A AFTER inner restore
13958        //   outer iter 2 (item=B):
13959        //     emit "(O=B"
13960        //     inner iter 1 (item=X) emit "[I=X]"
13961        //     inner iter 2 (item=Y) emit "[I=Y]"
13962        //     emit ",O=B)"
13963        assert_eq!(
13964            writer.get_content(),
13965            "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
13966            "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
13967        );
13968    }
13969    #[derive(Default)]
13970    struct FlushTestWriter {
13971        output: String,
13972        flushes: Vec<usize>,
13973        fail_flush: bool,
13974        fail_flush_at: Option<usize>,
13975        flush_attempts: usize,
13976        ended: bool,
13977    }
13978
13979    impl ResponseWriter for FlushTestWriter {
13980        fn write(&mut self, content: &str) -> Result<()> {
13981            self.output.push_str(content);
13982            Ok(())
13983        }
13984
13985        fn end(&mut self) -> Result<()> {
13986            self.ended = true;
13987            Ok(())
13988        }
13989    }
13990
13991    impl FlushWriter for FlushTestWriter {
13992        fn flush(&mut self) -> Result<()> {
13993            let attempt = self.flush_attempts;
13994            self.flush_attempts += 1;
13995            if self.fail_flush || self.fail_flush_at == Some(attempt) {
13996                return Err(HandlerError::ClientDisconnected);
13997            }
13998            self.flushes.push(self.output.len());
13999            Ok(())
14000        }
14001    }
14002
14003    fn streaming_protocol(with_boundaries: bool) -> Protocol {
14004        streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
14005    }
14006
14007    fn streaming_protocol_with_state_strategy(
14008        with_boundaries: bool,
14009        state_strategy: InitialStateStrategy,
14010    ) -> Protocol {
14011        let mut fragments = HashMap::new();
14012        let mut entry = vec![
14013            WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
14014            structural_fragment("head_start"),
14015            WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
14016            structural_fragment("head_end"),
14017            WebUIFragment::raw("</HEAD><body>"),
14018            structural_fragment("body_start"),
14019        ];
14020        if with_boundaries {
14021            entry.push(WebUIFragment::boundary(0, "index.html", "boundary-0", None));
14022        }
14023        entry.extend([
14024            WebUIFragment::raw("<my-counter"),
14025            structural_fragment("streaming_root:my-counter"),
14026            WebUIFragment::raw(">"),
14027            WebUIFragment::component("my-counter"),
14028            WebUIFragment::raw("</my-counter>"),
14029        ]);
14030        if with_boundaries {
14031            entry.push(WebUIFragment::boundary_end(0));
14032        }
14033        entry.extend([
14034            WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
14035            structural_fragment("body_end"),
14036            WebUIFragment::raw("</body></html>"),
14037        ]);
14038        fragments.insert(
14039            "index.html".to_string(),
14040            FragmentList {
14041                fragments: entry,
14042                contains_boundary: with_boundaries,
14043            },
14044        );
14045        fragments.insert(
14046            "my-counter".to_string(),
14047            FragmentList {
14048                fragments: vec![WebUIFragment::raw("<button>Count</button>")],
14049                contains_boundary: false,
14050            },
14051        );
14052
14053        let mut document = WebUIProtocol::new(fragments);
14054        document.initial_state_strategy = state_strategy as i32;
14055        document.components.insert(
14056            "my-counter".to_string(),
14057            webui_protocol::ComponentData {
14058                template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
14059                template_functions: "[function(){return true}]".to_string(),
14060                hydration_mode: StateProjectionMode::Keys as i32,
14061                hydration_keys: vec!["count".to_string()],
14062                ..Default::default()
14063            },
14064        );
14065        Protocol::new(document)
14066    }
14067
14068    #[test]
14069    fn handler_error_stays_small() {
14070        // Boxing the cold `StreamingBoundary` payload keeps `HandlerError` — and
14071        // therefore `Result<(), HandlerError>` threaded through the hot legacy
14072        // render path — down to a single `String`-sized payload plus a
14073        // discriminant word. If the boundary payload is un-boxed back to
14074        // `{ signal, reason }` it grows to two `String`s (48-byte payload) and
14075        // this fails.
14076        assert!(
14077            std::mem::size_of::<HandlerError>()
14078                <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
14079            "HandlerError grew to {} bytes",
14080            std::mem::size_of::<HandlerError>()
14081        );
14082    }
14083
14084    #[test]
14085    fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
14086        let protocol = streaming_protocol(true);
14087        let handler = WebUIHandler::with_plugin(|| {
14088            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14089        });
14090        let state = test_json!({ "count": 1, "serverOnly": "secret" });
14091        let mut writer = FlushTestWriter::default();
14092
14093        handler
14094            .render_streaming(
14095                &protocol,
14096                &state,
14097                &RenderOptions::new("index.html", "/"),
14098                &mut writer,
14099            )
14100            .unwrap();
14101
14102        assert!(writer.ended);
14103        assert_eq!(
14104            writer.flushes.len(),
14105            3,
14106            "bootstrap, boundary commit, and one coalesced terminal-tail flush"
14107        );
14108        let boundary_flush = &writer.output[writer.flushes[0]..writer.flushes[1]];
14109        assert!(boundary_flush.contains("<!--wb:0-->"));
14110        assert!(boundary_flush.contains("<!--/wb:0-->"));
14111        assert!(
14112            boundary_flush.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
14113            "boundary flush: {boundary_flush}"
14114        );
14115        assert!(boundary_flush.contains(r#""inventory":"01","state":{"count":1}"#));
14116        assert!(boundary_flush.contains(r#""templates":{"my-counter":"#));
14117        assert!(!boundary_flush.contains("slow tail"));
14118        assert!(!writer.output.contains("id=\"webui-data\""));
14119        // The terminal flush commits the scriptless tail without manufacturing
14120        // another state/template projection.
14121        let terminal_flush = &writer.output[writer.flushes[1]..writer.flushes[2]];
14122        assert!(terminal_flush.contains("slow tail"));
14123        assert!(writer.output.contains("[1,4,0,{}]"));
14124        assert!(!writer.output.contains("[1,0,1,"));
14125        assert!(!writer.output.contains("[2,4,0,{}]"));
14126
14127        let marker = writer
14128            .output
14129            .find(STREAMING_MARKER)
14130            .expect("streaming marker");
14131        let attributed_head = writer
14132            .output
14133            .find("<HEAD data-shell=\"main\">")
14134            .expect("attributed mixed-case head");
14135        let authored_script = writer
14136            .output
14137            .find("src=\"/index.js\"")
14138            .expect("entry script");
14139        assert!(attributed_head < marker && marker < authored_script);
14140    }
14141
14142    #[test]
14143    fn streaming_terminal_tail_never_resends_full_state() {
14144        let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
14145        let handler = WebUIHandler::with_plugin(|| {
14146            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14147        });
14148        let mut writer = FlushTestWriter::default();
14149
14150        handler
14151            .render_streaming(
14152                &protocol,
14153                &test_json!({ "count": 1, "serverOnly": "secret" }),
14154                &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
14155                &mut writer,
14156            )
14157            .unwrap();
14158
14159        assert_eq!(
14160            writer.output.matches("serverOnly").count(),
14161            1,
14162            "full state belongs only to the interactive boundary"
14163        );
14164        assert!(writer.output.contains("[1,4,0,{}]"));
14165    }
14166
14167    #[test]
14168    fn streaming_terminal_record_emits_before_body_close() {
14169        let fragments = HashMap::from([(
14170            "index.html".to_string(),
14171            FragmentList {
14172                fragments: vec![
14173                    WebUIFragment::raw("<html><head>"),
14174                    structural_fragment("head_start"),
14175                    structural_fragment("head_end"),
14176                    WebUIFragment::raw("</head><body>"),
14177                    structural_fragment("body_start"),
14178                    WebUIFragment::raw("<main>static</main>"),
14179                    structural_fragment("body_end"),
14180                    WebUIFragment::raw("</body></html>"),
14181                ],
14182                contains_boundary: false,
14183            },
14184        )]);
14185        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14186        let mut writer = FlushTestWriter::default();
14187
14188        WebUIHandler::new()
14189            .render_streaming(
14190                &protocol,
14191                &test_json!({}),
14192                &RenderOptions::new("index.html", "/"),
14193                &mut writer,
14194            )
14195            .unwrap();
14196
14197        let terminal = writer.output.find("[0,4,0,{}]").expect("terminal");
14198        let body_close = writer.output.find("</body>").expect("body close");
14199        assert!(
14200            terminal < body_close,
14201            "terminal boundary record must stay inside <body>: {}",
14202            writer.output
14203        );
14204    }
14205
14206    /// Streaming must place the reserved-state injects exactly where the
14207    /// ordinary render does, so a host can switch modes without its
14208    /// boundary HTML moving.
14209    #[test]
14210    fn state_inject_placement_matches_between_render_modes() {
14211        let entry = vec![
14212            WebUIFragment::raw("<html><head>"),
14213            structural_fragment("head_start"),
14214            structural_fragment("head_end"),
14215            WebUIFragment::raw("</head><body>"),
14216            structural_fragment("body_start"),
14217            WebUIFragment::raw("<main>static</main>"),
14218            structural_fragment("body_end"),
14219            WebUIFragment::raw("</body></html>"),
14220        ];
14221        let fragments = HashMap::from([(
14222            "index.html".to_string(),
14223            FragmentList {
14224                fragments: entry,
14225                contains_boundary: false,
14226            },
14227        )]);
14228        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14229        let state = test_json!({
14230            "$webui": {
14231                "headEnd": "<meta name=he>",
14232                "bodyStart": "<span id=bs></span>",
14233                "bodyEnd": "<script>be</script>",
14234            }
14235        });
14236        let options = RenderOptions::new("index.html", "/");
14237
14238        let mut ordinary = TestWriter::new();
14239        WebUIHandler::new()
14240            .render(&protocol, &state, &options, &mut ordinary)
14241            .unwrap();
14242        let ordinary_html = ordinary.get_content().to_string();
14243
14244        let mut streamed = FlushTestWriter::default();
14245        WebUIHandler::new()
14246            .render_streaming(&protocol, &state, &options, &mut streamed)
14247            .unwrap();
14248        let streamed_html = &streamed.output;
14249
14250        for html in [ordinary_html.as_str(), streamed_html.as_str()] {
14251            let head_end = html.find("<meta name=he>").expect("headEnd missing");
14252            let head_close = html.find("</head>").expect("</head> missing");
14253            let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
14254            let main = html.find("<main>static</main>").expect("content missing");
14255            let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
14256            let body_close = html.find("</body>").expect("</body> missing");
14257            assert!(head_end < head_close, "headEnd misplaced: {html}");
14258            assert!(body_start < main, "bodyStart misplaced: {html}");
14259            assert!(
14260                main < body_end && body_end < body_close,
14261                "bodyEnd misplaced: {html}"
14262            );
14263        }
14264
14265        // The streaming response still terminates with its single empty
14266        // terminal record: an inject must not perturb the record stream.
14267        assert!(
14268            streamed_html.contains(",4,0,{}]"),
14269            "streaming must still end in one empty terminal record: {streamed_html}"
14270        );
14271    }
14272
14273    #[test]
14274    fn streaming_state_inject_emits_and_strips_reserved_key() {
14275        let entry = vec![
14276            WebUIFragment::raw("<html><head>"),
14277            structural_fragment("head_start"),
14278            structural_fragment("head_end"),
14279            WebUIFragment::raw("</head><body>"),
14280            structural_fragment("body_start"),
14281            structural_fragment("body_end"),
14282            WebUIFragment::raw("</body></html>"),
14283        ];
14284        let fragments = HashMap::from([(
14285            "index.html".to_string(),
14286            FragmentList {
14287                fragments: entry,
14288                contains_boundary: false,
14289            },
14290        )]);
14291        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14292        let state = test_json!({ "$webui": { "bodyEnd": "<script>be</script>" } });
14293
14294        let mut writer = FlushTestWriter::default();
14295        WebUIHandler::new()
14296            .render_streaming(
14297                &protocol,
14298                &state,
14299                &RenderOptions::new("index.html", "/"),
14300                &mut writer,
14301            )
14302            .unwrap();
14303        assert!(writer.output.contains("<script>be</script>"));
14304        assert!(!writer.output.contains("$webui"));
14305    }
14306
14307    #[test]
14308    fn static_streaming_document_uses_one_empty_terminal_record() {
14309        let fragments = HashMap::from([(
14310            "index.html".to_string(),
14311            FragmentList {
14312                fragments: vec![
14313                    WebUIFragment::raw("<html><head>"),
14314                    structural_fragment("head_start"),
14315                    structural_fragment("head_end"),
14316                    WebUIFragment::raw("</head><body>"),
14317                    structural_fragment("body_start"),
14318                    WebUIFragment::raw("<main>static</main>"),
14319                    structural_fragment("body_end"),
14320                    WebUIFragment::raw("</body></html>"),
14321                ],
14322                contains_boundary: false,
14323            },
14324        )]);
14325        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14326        let mut writer = FlushTestWriter::default();
14327
14328        WebUIHandler::new()
14329            .render_streaming(
14330                &protocol,
14331                &test_json!({ "serverOnly": "secret" }),
14332                &RenderOptions::new("index.html", "/"),
14333                &mut writer,
14334            )
14335            .unwrap();
14336
14337        assert_eq!(writer.flushes.len(), 1);
14338        assert!(writer.output.contains(STREAMING_MARKER));
14339        assert!(writer.output.contains("[0,4,0,{}]"));
14340        assert!(!writer.output.contains("serverOnly"));
14341        assert!(!writer.output.contains("id=\"webui-data\""));
14342        assert!(!writer.output.contains("<!--wb:"));
14343    }
14344
14345    #[test]
14346    fn streaming_render_emits_nonce_and_functions_before_sentinel() {
14347        let protocol = streaming_protocol(true);
14348        let handler = WebUIHandler::with_plugin(|| {
14349            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14350        });
14351        let mut writer = FlushTestWriter::default();
14352        handler
14353            .render_streaming(
14354                &protocol,
14355                &test_json!({ "count": 1 }),
14356                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
14357                &mut writer,
14358            )
14359            .unwrap();
14360
14361        let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
14362        let envelope = writer.output[boundary_end..]
14363            .find("data-webui-boundary nonce=\"test-nonce-123\"")
14364            .map(|index| index + boundary_end)
14365            .expect("nonce-bearing envelope");
14366        let functions = writer
14367            .output
14368            .find("templateFns")
14369            .expect("function side channel");
14370        let sentinel = writer
14371            .output
14372            .find("<webui-hydrate>")
14373            .expect("hydration sentinel");
14374        assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
14375        assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
14376    }
14377
14378    #[test]
14379    fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
14380        fn protocol(boundaries: Vec<WebUIFragment>) -> Protocol {
14381            let mut fragments = vec![structural_fragment("head_start")];
14382            fragments.extend(boundaries);
14383            let fragments = HashMap::from([(
14384                "index.html".to_string(),
14385                FragmentList {
14386                    fragments,
14387                    contains_boundary: true,
14388                },
14389            )]);
14390            Protocol::new(WebUIProtocol::new(fragments))
14391        }
14392
14393        let handler = WebUIHandler::new();
14394        let mut writer = FlushTestWriter::default();
14395        let result = handler.render_streaming(
14396            &protocol(vec![WebUIFragment::boundary_end(0)]),
14397            &test_json!({}),
14398            &RenderOptions::new("index.html", "/"),
14399            &mut writer,
14400        );
14401        match result {
14402            Err(HandlerError::StreamingBoundary(err)) => {
14403                assert_eq!(err.signal, "boundary");
14404                assert!(err.reason.contains("no active occurrence"));
14405            }
14406            other => panic!("expected ordered-boundary error, got {other:?}"),
14407        }
14408
14409        let mut writer = FlushTestWriter::default();
14410        let result = handler.render_streaming(
14411            &protocol(vec![
14412                WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14413                WebUIFragment::boundary(1, "index.html", "boundary-1", None),
14414            ]),
14415            &test_json!({}),
14416            &RenderOptions::new("index.html", "/"),
14417            &mut writer,
14418        );
14419        assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
14420    }
14421
14422    #[test]
14423    fn streaming_render_propagates_boundary_flush_disconnect() {
14424        let protocol = streaming_protocol(true);
14425        let handler = WebUIHandler::new();
14426        let mut writer = FlushTestWriter {
14427            fail_flush: true,
14428            ..FlushTestWriter::default()
14429        };
14430        let result = handler.render_streaming(
14431            &protocol,
14432            &test_json!({}),
14433            &RenderOptions::new("index.html", "/"),
14434            &mut writer,
14435        );
14436        assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
14437        assert!(!writer.ended);
14438    }
14439
14440    #[test]
14441    fn streaming_render_requires_structural_head_start_before_writing() {
14442        let fragments = HashMap::from([(
14443            "index.html".to_string(),
14444            FragmentList {
14445                fragments: vec![
14446                    WebUIFragment::raw(
14447                        "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
14448                    ),
14449                    structural_fragment("head_end"),
14450                    WebUIFragment::raw("</HEAD><body>"),
14451                    structural_fragment("body_start"),
14452                    structural_fragment("body_end"),
14453                ],
14454                contains_boundary: false,
14455            },
14456        )]);
14457        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14458        let mut writer = FlushTestWriter::default();
14459
14460        let result = WebUIHandler::new().render_streaming(
14461            &protocol,
14462            &test_json!({}),
14463            &RenderOptions::new("index.html", "/"),
14464            &mut writer,
14465        );
14466
14467        assert!(matches!(
14468            result,
14469            Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
14470        ));
14471        assert_eq!(
14472            writer.output,
14473            "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>"
14474        );
14475        assert!(writer.flushes.is_empty());
14476        assert!(!writer.ended);
14477    }
14478
14479    #[test]
14480    fn streaming_render_rejects_duplicate_head_start() {
14481        let fragments = HashMap::from([(
14482            "index.html".to_string(),
14483            FragmentList {
14484                fragments: vec![
14485                    WebUIFragment::raw("<html><head>"),
14486                    structural_fragment("head_start"),
14487                    structural_fragment("head_start"),
14488                    structural_fragment("head_end"),
14489                    WebUIFragment::raw("</head><body>"),
14490                    structural_fragment("body_start"),
14491                    structural_fragment("body_end"),
14492                ],
14493                contains_boundary: false,
14494            },
14495        )]);
14496        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14497        let mut writer = FlushTestWriter::default();
14498
14499        let result = WebUIHandler::new().render_streaming(
14500            &protocol,
14501            &test_json!({}),
14502            &RenderOptions::new("index.html", "/"),
14503            &mut writer,
14504        );
14505
14506        assert!(matches!(
14507            result,
14508            Err(HandlerError::DuplicateStreamingHeadStart)
14509        ));
14510        assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
14511        assert!(writer.flushes.is_empty());
14512    }
14513
14514    #[test]
14515    fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
14516        let handler = WebUIHandler::with_plugin(|| {
14517            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14518        });
14519        let state = test_json!({
14520            "count": 1,
14521            "head_start": "must not render",
14522            "boundary_start:0": "must not render",
14523            "boundary_end:0": "must not render",
14524        });
14525        let mut with_boundaries = TestWriter::new();
14526        let mut without_boundaries = TestWriter::new();
14527        handler
14528            .render(
14529                &streaming_protocol(true),
14530                &state,
14531                &RenderOptions::new("index.html", "/"),
14532                &mut with_boundaries,
14533            )
14534            .unwrap();
14535        handler
14536            .render(
14537                &streaming_protocol(false),
14538                &state,
14539                &RenderOptions::new("index.html", "/"),
14540                &mut without_boundaries,
14541            )
14542            .unwrap();
14543        assert_eq!(
14544            with_boundaries.get_content(),
14545            without_boundaries.get_content()
14546        );
14547    }
14548
14549    #[test]
14550    fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
14551        for source in [
14552            r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
14553            r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
14554        ] {
14555            let mut parser = HtmlParser::new();
14556            parser
14557                .parse("index.html", source)
14558                .expect("parse head fixture");
14559            let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14560            let mut writer = TestWriter::new();
14561            WebUIHandler::new()
14562                .render(
14563                    &protocol,
14564                    &test_json!({ "theme": "light" }),
14565                    &RenderOptions::new("index.html", "/"),
14566                    &mut writer,
14567                )
14568                .unwrap();
14569
14570            assert_eq!(
14571                writer.get_content(),
14572                "<html><head><title>T</title></head><body>x</body></html>"
14573            );
14574        }
14575    }
14576
14577    #[test]
14578    fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
14579        let source =
14580            r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
14581        let mut parser = HtmlParser::new();
14582        parser.parse("index.html", source).expect("parse fixture");
14583        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14584
14585        let mut ordinary = TestWriter::new();
14586        WebUIHandler::new()
14587            .render(
14588                &protocol,
14589                &test_json!({}),
14590                &RenderOptions::new("index.html", "/"),
14591                &mut ordinary,
14592            )
14593            .unwrap();
14594        assert_eq!(ordinary.get_content(), source);
14595
14596        let mut streaming = FlushTestWriter::default();
14597        WebUIHandler::new()
14598            .render_streaming(
14599                &protocol,
14600                &test_json!({}),
14601                &RenderOptions::new("index.html", "/"),
14602                &mut streaming,
14603            )
14604            .unwrap();
14605        assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
14606        assert!(streaming.output.contains("</HEAD><BODY>x"));
14607        let opening = streaming.output.find("<HEAD").expect("mixed-case head");
14608        let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
14609        let title = streaming.output.find("<title>").expect("title");
14610        assert!(opening < marker && marker < title);
14611        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14612    }
14613
14614    /// Only `}}}webui:`-namespaced signals are compiler-owned, so an
14615    /// unprefixed `body_end` is ordinary authored content.
14616    #[test]
14617    fn unnamespaced_signal_is_ordinary_content() {
14618        let fragments = HashMap::from([(
14619            "index.html".to_string(),
14620            FragmentList {
14621                fragments: vec![
14622                    WebUIFragment::raw("<html><head>"),
14623                    structural_fragment("head_end"),
14624                    WebUIFragment::raw("</head><body>"),
14625                    WebUIFragment::signal("body_end".to_string(), false),
14626                    structural_fragment("body_end"),
14627                    WebUIFragment::raw("</body></html>"),
14628                ],
14629                contains_boundary: false,
14630            },
14631        )]);
14632        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14633        let mut writer = TestWriter::new();
14634        WebUIHandler::new()
14635            .render(
14636                &protocol,
14637                &test_json!({ "body_end": "content" }),
14638                &RenderOptions::new("index.html", "/"),
14639                &mut writer,
14640            )
14641            .expect("current protocol must render");
14642        assert!(writer.get_content().contains("content"));
14643    }
14644
14645    #[test]
14646    fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
14647        let source = concat!(
14648            "<html><head></head><body>",
14649            "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
14650            "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
14651            "</body></html>",
14652        );
14653        let state = test_json!({
14654            "head_start": "hs",
14655            "head_end": "he",
14656            "body_start": "bs",
14657            "body_end": "be",
14658            "boundary_start:0": "b0s",
14659            "boundary_end:0": "b0e",
14660            "streaming_root:forged": "root",
14661        });
14662        let mut parser = HtmlParser::new();
14663        parser.parse("index.html", source).expect("parse fixture");
14664        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14665        let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
14666
14667        let mut ordinary = TestWriter::new();
14668        WebUIHandler::new()
14669            .render(
14670                &protocol,
14671                &state,
14672                &RenderOptions::new("index.html", "/"),
14673                &mut ordinary,
14674            )
14675            .unwrap();
14676        assert_eq!(ordinary.get_content(), expected);
14677
14678        let mut streaming = FlushTestWriter::default();
14679        WebUIHandler::new()
14680            .render_streaming(
14681                &protocol,
14682                &state,
14683                &RenderOptions::new("index.html", "/"),
14684                &mut streaming,
14685            )
14686            .unwrap();
14687        assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
14688        assert!(!streaming.output.contains("<!--wb:"));
14689        assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
14690        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14691    }
14692
14693    #[test]
14694    fn streaming_rejects_structural_signals_after_terminal() {
14695        let fragments = HashMap::from([(
14696            "index.html".to_string(),
14697            FragmentList {
14698                fragments: vec![
14699                    WebUIFragment::raw("<html><head>"),
14700                    structural_fragment("head_start"),
14701                    structural_fragment("head_end"),
14702                    WebUIFragment::raw("</head><body>"),
14703                    structural_fragment("body_start"),
14704                    structural_fragment("body_end"),
14705                    WebUIFragment::raw("</body>"),
14706                    structural_fragment("boundary_start:0"),
14707                    WebUIFragment::raw("</html>"),
14708                ],
14709                contains_boundary: false,
14710            },
14711        )]);
14712        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14713        let mut streaming = FlushTestWriter::default();
14714        let result = WebUIHandler::new().render_streaming(
14715            &protocol,
14716            &test_json!({}),
14717            &RenderOptions::new("index.html", "/"),
14718            &mut streaming,
14719        );
14720
14721        match result {
14722            Err(HandlerError::StreamingBoundary(error)) => {
14723                assert_eq!(error.signal, "boundary_start:0");
14724                assert!(
14725                    error
14726                        .reason
14727                        .contains("structural signal arrived after body_end"),
14728                    "reason: {}",
14729                    error.reason
14730                );
14731            }
14732            other => panic!("expected post-terminal rejection, got {other:?}"),
14733        }
14734        assert_eq!(
14735            streaming.output,
14736            concat!(
14737                "<html><head><meta name=\"webui-streaming\" content=\"1\"></head><body>",
14738                "<script type=\"application/json\" data-webui-boundary>[0,4,0,{}]</script>",
14739                "<webui-hydrate></webui-hydrate></body>"
14740            )
14741        );
14742        assert!(!streaming.output.contains("<!--wb:0-->"));
14743
14744        let mut ordinary = TestWriter::new();
14745        WebUIHandler::new()
14746            .render(
14747                &protocol,
14748                &test_json!({}),
14749                &RenderOptions::new("index.html", "/"),
14750                &mut ordinary,
14751            )
14752            .unwrap();
14753        assert_eq!(
14754            ordinary.get_content(),
14755            "<html><head></head><body></body></html>"
14756        );
14757    }
14758
14759    fn disjoint_streaming_protocol_ext(
14760        hosts: &[&str],
14761        emit_root_signal: bool,
14762        include_styles: bool,
14763    ) -> Protocol {
14764        let mut fragments = HashMap::new();
14765        let mut entry = vec![
14766            WebUIFragment::raw("<!DOCTYPE html><html><head>"),
14767            structural_fragment("head_start"),
14768            structural_fragment("head_end"),
14769            WebUIFragment::raw("</head><body>"),
14770            structural_fragment("body_start"),
14771        ];
14772        for (sequence, host) in hosts.iter().enumerate() {
14773            entry.push(WebUIFragment::boundary(
14774                sequence as u32,
14775                "index.html",
14776                format!("boundary-{sequence}"),
14777                None,
14778            ));
14779            entry.push(WebUIFragment::raw(format!("<{host}")));
14780            if emit_root_signal {
14781                entry.push(structural_fragment(format!("streaming_root:{host}")));
14782            }
14783            entry.push(WebUIFragment::raw(">"));
14784            entry.push(WebUIFragment::component(*host));
14785            entry.push(WebUIFragment::raw(format!("</{host}>")));
14786            entry.push(WebUIFragment::boundary_end(sequence as u32));
14787        }
14788        entry.push(structural_fragment("body_end"));
14789        entry.push(WebUIFragment::raw("</body></html>"));
14790        fragments.insert(
14791            "index.html".to_string(),
14792            FragmentList {
14793                fragments: entry,
14794                contains_boundary: true,
14795            },
14796        );
14797        fragments.insert(
14798            "comp-a".to_string(),
14799            FragmentList {
14800                fragments: vec![WebUIFragment::raw("<b>A</b>")],
14801                contains_boundary: false,
14802            },
14803        );
14804        fragments.insert(
14805            "comp-b".to_string(),
14806            FragmentList {
14807                fragments: vec![WebUIFragment::raw("<b>B</b>")],
14808                contains_boundary: false,
14809            },
14810        );
14811
14812        let mut document = WebUIProtocol::new(fragments);
14813        document.initial_state_strategy = InitialStateStrategy::Components as i32;
14814        document.components.insert(
14815            "comp-a".to_string(),
14816            webui_protocol::ComponentData {
14817                template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
14818                hydration_mode: StateProjectionMode::Keys as i32,
14819                hydration_keys: vec!["a_count".to_string()],
14820                ..Default::default()
14821            },
14822        );
14823        document.components.insert(
14824            "comp-b".to_string(),
14825            webui_protocol::ComponentData {
14826                template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
14827                hydration_mode: StateProjectionMode::Keys as i32,
14828                hydration_keys: vec!["b_count".to_string()],
14829                ..Default::default()
14830            },
14831        );
14832        if include_styles {
14833            document.set_css_strategy(webui_protocol::CssStrategy::Style);
14834            document
14835                .components
14836                .get_mut("comp-a")
14837                .expect("comp-a component")
14838                .css = ".comp-a{color:red}".to_string();
14839            document
14840                .components
14841                .get_mut("comp-b")
14842                .expect("comp-b component")
14843                .css = ".comp-b{color:blue}".to_string();
14844            document.populate_style_closures(&["index.html"]);
14845        }
14846        Protocol::new(document)
14847    }
14848
14849    fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
14850        disjoint_streaming_protocol_ext(hosts, true, false)
14851    }
14852
14853    fn styled_disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
14854        disjoint_streaming_protocol_ext(hosts, true, true)
14855    }
14856
14857    fn streaming_root_validation_protocol(
14858        mut host_fragments: Vec<WebUIFragment>,
14859        tail: Vec<WebUIFragment>,
14860    ) -> Protocol {
14861        let mut entry = vec![
14862            WebUIFragment::raw("<html><head>"),
14863            structural_fragment("head_start"),
14864            structural_fragment("head_end"),
14865            WebUIFragment::raw("</head><body>"),
14866            structural_fragment("body_start"),
14867            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14868        ];
14869        entry.append(&mut host_fragments);
14870        entry.extend(tail);
14871        let fragments = HashMap::from([
14872            (
14873                "index.html".to_string(),
14874                FragmentList {
14875                    fragments: entry,
14876                    contains_boundary: true,
14877                },
14878            ),
14879            (
14880                "comp-a".to_string(),
14881                FragmentList {
14882                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
14883                    contains_boundary: false,
14884                },
14885            ),
14886            (
14887                "comp-b".to_string(),
14888                FragmentList {
14889                    fragments: vec![WebUIFragment::raw("<b>B</b>")],
14890                    contains_boundary: false,
14891                },
14892            ),
14893        ]);
14894        Protocol::new(WebUIProtocol::new(fragments))
14895    }
14896
14897    fn completed_streaming_tail() -> Vec<WebUIFragment> {
14898        vec![
14899            WebUIFragment::raw("</comp-a>"),
14900            WebUIFragment::boundary_end(0),
14901            structural_fragment("body_end"),
14902            WebUIFragment::raw("</body></html>"),
14903        ]
14904    }
14905
14906    fn assert_streaming_root_error(
14907        protocol: &Protocol,
14908        expected_signal: &str,
14909        expected_reason: &str,
14910    ) {
14911        let mut writer = FlushTestWriter::default();
14912        let result = WebUIHandler::new().render_streaming(
14913            protocol,
14914            &test_json!({}),
14915            &RenderOptions::new("index.html", "/"),
14916            &mut writer,
14917        );
14918        match result {
14919            Err(HandlerError::StreamingBoundary(error)) => {
14920                assert_eq!(error.signal, expected_signal);
14921                assert!(
14922                    error.reason.contains(expected_reason),
14923                    "reason: {}",
14924                    error.reason
14925                );
14926            }
14927            other => panic!("expected streaming-root rejection, got {other:?}"),
14928        }
14929    }
14930
14931    #[test]
14932    fn streaming_root_signal_injects_data_ws_inside_boundary() {
14933        let protocol = disjoint_streaming_protocol(&["comp-a"]);
14934        let handler = WebUIHandler::with_plugin(|| {
14935            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14936        });
14937        let mut writer = FlushTestWriter::default();
14938        handler
14939            .render_streaming(
14940                &protocol,
14941                &test_json!({ "a_count": 1 }),
14942                &RenderOptions::new("index.html", "/"),
14943                &mut writer,
14944            )
14945            .unwrap();
14946        // The parser-owned signal is consumed to inject exactly ` data-ws`
14947        // inside the host's opening tag, before the custom element upgrades.
14948        assert!(
14949            writer.output.contains("<comp-a data-ws>"),
14950            "streamed host must carry data-ws: {}",
14951            writer.output
14952        );
14953    }
14954
14955    #[test]
14956    fn streaming_root_signal_preserves_ordinary_output_bytes() {
14957        // Ordinary rendering ignores `streaming_root` byte-for-byte: identical
14958        // output with and without the signal, and never a `data-ws` attribute.
14959        let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true, false);
14960        let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false, false);
14961        let state = test_json!({ "a_count": 1, "b_count": 2 });
14962        let plugin = || {
14963            WebUIHandler::with_plugin(
14964                || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
14965            )
14966        };
14967        let mut with_writer = TestWriter::new();
14968        let mut without_writer = TestWriter::new();
14969        plugin()
14970            .render(
14971                &with_signal,
14972                &state,
14973                &RenderOptions::new("index.html", "/"),
14974                &mut with_writer,
14975            )
14976            .unwrap();
14977        plugin()
14978            .render(
14979                &without_signal,
14980                &state,
14981                &RenderOptions::new("index.html", "/"),
14982                &mut without_writer,
14983            )
14984            .unwrap();
14985        // The rendered DOM (everything up to the inert data block) is
14986        // deterministic and is where a leaked `data-ws` would appear. The
14987        // ordinary template map is HashSet-ordered, so compare the DOM prefix
14988        // for byte identity rather than the whole document.
14989        let dom_prefix = |content: &str| -> String {
14990            content
14991                .split_once(r#"<script type="application/json" id="webui-data""#)
14992                .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
14993        };
14994        assert_eq!(
14995            dom_prefix(&with_writer.get_content()),
14996            dom_prefix(&without_writer.get_content())
14997        );
14998        assert!(!with_writer.get_content().contains("data-ws"));
14999        assert!(with_writer.get_content().contains("<comp-a>"));
15000    }
15001
15002    #[test]
15003    fn streaming_component_without_root_signal_is_rejected() {
15004        let protocol = streaming_root_validation_protocol(
15005            vec![
15006                WebUIFragment::raw("<comp-a>"),
15007                WebUIFragment::component("comp-a"),
15008            ],
15009            completed_streaming_tail(),
15010        );
15011
15012        assert_streaming_root_error(
15013            &protocol,
15014            "comp-a",
15015            "has no compiler-owned streaming_root signal",
15016        );
15017    }
15018
15019    #[test]
15020    fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
15021        let duplicate = streaming_root_validation_protocol(
15022            vec![
15023                WebUIFragment::raw("<comp-a"),
15024                structural_fragment("streaming_root:comp-a"),
15025                structural_fragment("streaming_root:comp-a"),
15026                WebUIFragment::raw(">"),
15027                WebUIFragment::component("comp-a"),
15028            ],
15029            completed_streaming_tail(),
15030        );
15031        assert_streaming_root_error(&duplicate, "comp-a", "is misplaced");
15032
15033        let after_tag_close = streaming_root_validation_protocol(
15034            vec![
15035                WebUIFragment::raw("<comp-a>"),
15036                structural_fragment("streaming_root:comp-a"),
15037                WebUIFragment::component("comp-a"),
15038            ],
15039            completed_streaming_tail(),
15040        );
15041        assert_streaming_root_error(&after_tag_close, "comp-a", "is misplaced");
15042
15043        let detached_from_opening = streaming_root_validation_protocol(
15044            vec![
15045                WebUIFragment::raw("not an opening tag"),
15046                structural_fragment("streaming_root:comp-a"),
15047                WebUIFragment::raw(">"),
15048                WebUIFragment::component("comp-a"),
15049            ],
15050            completed_streaming_tail(),
15051        );
15052        assert_streaming_root_error(
15053            &detached_from_opening,
15054            "comp-a",
15055            "unclosed component opening-tag close",
15056        );
15057    }
15058
15059    #[test]
15060    fn mismatched_streaming_root_signal_is_rejected() {
15061        let protocol = streaming_root_validation_protocol(
15062            vec![
15063                WebUIFragment::raw("<comp-a"),
15064                structural_fragment("streaming_root:comp-b"),
15065                WebUIFragment::raw(">"),
15066                WebUIFragment::component("comp-a"),
15067            ],
15068            completed_streaming_tail(),
15069        );
15070
15071        assert_streaming_root_error(&protocol, "comp-b", "root signal names <comp-b>");
15072    }
15073
15074    #[test]
15075    fn pending_streaming_root_is_rejected_at_structural_ends() {
15076        let protocol = streaming_root_validation_protocol(
15077            vec![
15078                WebUIFragment::raw("<comp-a"),
15079                structural_fragment("streaming_root:comp-a"),
15080                WebUIFragment::raw(">"),
15081            ],
15082            vec![structural_fragment("boundary_end:0")],
15083        );
15084        assert_streaming_root_error(&protocol, "comp-a", "is misplaced");
15085    }
15086
15087    #[test]
15088    fn streaming_root_outside_boundary_is_rejected() {
15089        let fragments = HashMap::from([
15090            (
15091                "index.html".to_string(),
15092                FragmentList {
15093                    fragments: vec![
15094                        WebUIFragment::raw("<html><head>"),
15095                        structural_fragment("head_start"),
15096                        structural_fragment("head_end"),
15097                        WebUIFragment::raw("</head><body>"),
15098                        structural_fragment("body_start"),
15099                        WebUIFragment::raw("<comp-a"),
15100                        structural_fragment("streaming_root:comp-a"),
15101                        WebUIFragment::raw(">"),
15102                        WebUIFragment::component("comp-a"),
15103                        WebUIFragment::raw("</comp-a>"),
15104                        structural_fragment("body_end"),
15105                        WebUIFragment::raw("</body></html>"),
15106                    ],
15107                    contains_boundary: false,
15108                },
15109            ),
15110            (
15111                "comp-a".to_string(),
15112                FragmentList {
15113                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
15114                    contains_boundary: false,
15115                },
15116            ),
15117        ]);
15118        let protocol = Protocol::new(WebUIProtocol::new(fragments));
15119        let mut writer = FlushTestWriter::default();
15120        let result = WebUIHandler::new().render_streaming(
15121            &protocol,
15122            &test_json!({}),
15123            &RenderOptions::new("index.html", "/"),
15124            &mut writer,
15125        );
15126        match result {
15127            Err(HandlerError::StreamingBoundary(err)) => {
15128                assert_eq!(err.signal, "comp-a");
15129                assert!(
15130                    err.reason
15131                        .contains("no boundary or generated component span"),
15132                    "reason: {}",
15133                    err.reason
15134                );
15135            }
15136            other => panic!("expected outside-boundary rejection, got {other:?}"),
15137        }
15138    }
15139
15140    fn parser_route_protocol(with_boundary: bool) -> Protocol {
15141        let mut parser = HtmlParser::with_options(DomStrategy::Light);
15142        parser
15143            .component_registry_mut()
15144            .register_component(ComponentRegistration::new(
15145                "route-page",
15146                "<p>route content</p>",
15147                None,
15148                true,
15149            ))
15150            .expect("register route component");
15151        let route = r#"<route path="/" component="route-page" exact />"#;
15152        let html = if with_boundary {
15153            format!(
15154                "<html><head></head><body>\
15155                 <boundary name=\"route\">{route}</boundary>\
15156                 </body></html>"
15157            )
15158        } else {
15159            format!("<html><head></head><body>{route}</body></html>")
15160        };
15161        parser
15162            .parse("index.html", &html)
15163            .expect("parse route streaming fixture");
15164        let mut document = WebUIProtocol::new(parser.into_fragment_records());
15165        document.components.insert(
15166            "route-page".to_string(),
15167            webui_protocol::ComponentData::default(),
15168        );
15169        Protocol::new(document)
15170    }
15171
15172    #[test]
15173    fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
15174        let protocol = parser_route_protocol(true);
15175        let mut writer = FlushTestWriter::default();
15176        WebUIHandler::new()
15177            .render_streaming(
15178                &protocol,
15179                &test_json!({}),
15180                &RenderOptions::new("index.html", "/"),
15181                &mut writer,
15182            )
15183            .unwrap();
15184
15185        assert!(
15186            writer
15187                .output
15188                .contains("<route-page data-ws><p>route content</p></route-page>"),
15189            "matched route host must be deferred before upgrade: {}",
15190            writer.output
15191        );
15192    }
15193
15194    #[test]
15195    fn streaming_parser_route_host_outside_boundary_is_rejected() {
15196        let protocol = parser_route_protocol(false);
15197        let mut writer = FlushTestWriter::default();
15198        let result = WebUIHandler::new().render_streaming(
15199            &protocol,
15200            &test_json!({}),
15201            &RenderOptions::new("index.html", "/"),
15202            &mut writer,
15203        );
15204
15205        match result {
15206            Err(HandlerError::StreamingBoundary(error)) => {
15207                assert_eq!(error.signal, "route-page");
15208                assert!(
15209                    error
15210                        .reason
15211                        .contains("no boundary or generated component span"),
15212                    "reason: {}",
15213                    error.reason
15214                );
15215            }
15216            other => panic!("expected route boundary rejection, got {other:?}"),
15217        }
15218        assert!(
15219            !writer.output.contains("<route-page>"),
15220            "an unmarked custom-element opening must never be completed"
15221        );
15222    }
15223
15224    #[test]
15225    fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
15226        let entry = vec![
15227            WebUIFragment::raw("<html><head>"),
15228            structural_fragment("head_start"),
15229            structural_fragment("head_end"),
15230            WebUIFragment::raw("</head><body>"),
15231            structural_fragment("body_start"),
15232            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15233            WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
15234                path: "/account".into(),
15235                fragment_id: "account-shell".into(),
15236                exact: false,
15237                ..Default::default()
15238            }),
15239            WebUIFragment::boundary_end(0),
15240            structural_fragment("body_end"),
15241            WebUIFragment::raw("</body></html>"),
15242        ];
15243        let fragments = HashMap::from([
15244            (
15245                "index.html".to_string(),
15246                FragmentList {
15247                    fragments: entry,
15248                    contains_boundary: true,
15249                },
15250            ),
15251            (
15252                "account-shell".to_string(),
15253                FragmentList {
15254                    fragments: vec![WebUIFragment::route_from(
15255                        webui_protocol::WebUiFragmentRoute {
15256                            path: "./details".into(),
15257                            fragment_id: "details-page".into(),
15258                            exact: true,
15259                            pending_component: "details-loading".into(),
15260                            error_component: "details-error".into(),
15261                            ..Default::default()
15262                        },
15263                    )],
15264                    contains_boundary: false,
15265                },
15266            ),
15267            (
15268                "details-page".to_string(),
15269                FragmentList {
15270                    fragments: vec![WebUIFragment::raw("<p>details</p>")],
15271                    contains_boundary: false,
15272                },
15273            ),
15274            (
15275                "details-loading".to_string(),
15276                FragmentList {
15277                    fragments: vec![WebUIFragment::raw("<p>loading</p>")],
15278                    contains_boundary: false,
15279                },
15280            ),
15281            (
15282                "details-error".to_string(),
15283                FragmentList {
15284                    fragments: vec![WebUIFragment::raw("<p>error</p>")],
15285                    contains_boundary: false,
15286                },
15287            ),
15288        ]);
15289        let mut document = WebUIProtocol::new(fragments);
15290        document.initial_state_strategy = InitialStateStrategy::Components as i32;
15291        for name in [
15292            "account-shell",
15293            "details-page",
15294            "details-loading",
15295            "details-error",
15296        ] {
15297            document.components.insert(
15298                name.to_string(),
15299                webui_protocol::ComponentData {
15300                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
15301                    ..Default::default()
15302                },
15303            );
15304        }
15305        let protocol = Protocol::new(document);
15306        let handler = WebUIHandler::with_plugin(|| {
15307            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15308        });
15309        let mut writer = FlushTestWriter::default();
15310
15311        handler
15312            .render_streaming(
15313                &protocol,
15314                &test_json!({}),
15315                &RenderOptions::new("index.html", "/account/details"),
15316                &mut writer,
15317            )
15318            .unwrap();
15319
15320        let first = &writer.output[writer.flushes[0]..writer.flushes[1]];
15321        assert!(
15322            first.contains(r#""details-loading":"#),
15323            "relative-route pending metadata missing: {first}"
15324        );
15325        assert!(
15326            first.contains(r#""details-error":"#),
15327            "relative-route error metadata missing: {first}"
15328        );
15329    }
15330
15331    #[test]
15332    fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
15333        let entry = vec![
15334            WebUIFragment::raw("<html><head>"),
15335            structural_fragment("head_start"),
15336            structural_fragment("head_end"),
15337            WebUIFragment::raw("</head><body>"),
15338            structural_fragment("body_start"),
15339            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15340            WebUIFragment::raw("<static-shell"),
15341            structural_fragment("streaming_root:static-shell"),
15342            WebUIFragment::raw(">"),
15343            WebUIFragment::component("static-shell"),
15344            WebUIFragment::raw("</static-shell>"),
15345            WebUIFragment::raw("<route-shell"),
15346            structural_fragment("streaming_root:route-shell"),
15347            WebUIFragment::raw(">"),
15348            WebUIFragment::component("route-shell"),
15349            WebUIFragment::raw("</route-shell>"),
15350            WebUIFragment::boundary_end(0),
15351            structural_fragment("body_end"),
15352            WebUIFragment::raw("</body></html>"),
15353        ];
15354        let fragments = HashMap::from([
15355            (
15356                "index.html".to_string(),
15357                FragmentList {
15358                    fragments: entry,
15359                    contains_boundary: true,
15360                },
15361            ),
15362            (
15363                "static-shell".to_string(),
15364                FragmentList {
15365                    fragments: vec![WebUIFragment::if_cond(
15366                        ConditionExpr::identifier("show_hidden"),
15367                        "static-hidden-if",
15368                    )],
15369                    contains_boundary: false,
15370                },
15371            ),
15372            (
15373                "static-hidden-if".to_string(),
15374                FragmentList {
15375                    fragments: vec![WebUIFragment::component("static-hidden")],
15376                    contains_boundary: false,
15377                },
15378            ),
15379            (
15380                "static-hidden".to_string(),
15381                FragmentList {
15382                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15383                    contains_boundary: false,
15384                },
15385            ),
15386            (
15387                "route-shell".to_string(),
15388                FragmentList {
15389                    fragments: vec![WebUIFragment::route_from(
15390                        webui_protocol::WebUiFragmentRoute {
15391                            path: "/account".into(),
15392                            fragment_id: "route-page".into(),
15393                            exact: true,
15394                            ..Default::default()
15395                        },
15396                    )],
15397                    contains_boundary: false,
15398                },
15399            ),
15400            (
15401                "route-page".to_string(),
15402                FragmentList {
15403                    fragments: vec![WebUIFragment::raw("<p>route</p>")],
15404                    contains_boundary: false,
15405                },
15406            ),
15407        ]);
15408        let mut document = WebUIProtocol::new(fragments);
15409        document.set_css_strategy(webui_protocol::CssStrategy::Link);
15410        document.initial_state_strategy = InitialStateStrategy::Components as i32;
15411        for (name, keys) in [
15412            ("static-shell", vec!["show_hidden", "static_count"]),
15413            ("static-hidden", vec!["hidden_count"]),
15414            ("route-shell", vec!["route_shell_count"]),
15415            ("route-page", vec!["route_count"]),
15416        ] {
15417            document.components.insert(
15418                name.to_string(),
15419                webui_protocol::ComponentData {
15420                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
15421                    hydration_mode: StateProjectionMode::Keys as i32,
15422                    hydration_keys: keys.into_iter().map(str::to_string).collect(),
15423                    css_href: format!("/{name}.css"),
15424                    ..Default::default()
15425                },
15426            );
15427        }
15428        document.populate_style_closures(&["index.html"]);
15429        let protocol = Protocol::new(document);
15430        assert_eq!(
15431            protocol
15432                .component_reachability()
15433                .is_route_dependent(protocol.component_index()["route-shell"]),
15434            Some(true),
15435            "fixture must exercise request-aware checkpoint reachability"
15436        );
15437        let handler = WebUIHandler::with_plugin(|| {
15438            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15439        });
15440        let mut writer = FlushTestWriter::default();
15441
15442        handler
15443            .render_streaming(
15444                &protocol,
15445                &test_json!({
15446                    "show_hidden": false,
15447                    "static_count": 1,
15448                    "hidden_count": 2,
15449                    "route_shell_count": 3,
15450                    "route_count": 4,
15451                    "unrelated": "private",
15452                }),
15453                &RenderOptions::new("index.html", "/account"),
15454                &mut writer,
15455            )
15456            .unwrap();
15457
15458        let checkpoint = &writer.output[writer.flushes[0]..writer.flushes[1]];
15459        for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
15460            assert!(
15461                checkpoint.contains(&format!(r#""{template}":"#)),
15462                "{template} metadata missing: {checkpoint}"
15463            );
15464        }
15465        for state in [
15466            r#""show_hidden":false"#,
15467            r#""static_count":1"#,
15468            r#""hidden_count":2"#,
15469            r#""route_shell_count":3"#,
15470            r#""route_count":4"#,
15471        ] {
15472            assert!(
15473                checkpoint.contains(state),
15474                "{state} state missing: {checkpoint}"
15475            );
15476        }
15477        assert!(
15478            !checkpoint.contains("unrelated"),
15479            "checkpoint leaked unrelated state: {checkpoint}"
15480        );
15481        assert!(
15482            checkpoint.contains(
15483                r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
15484            ),
15485            "checkpoint metadata lost source order: {checkpoint}"
15486        );
15487        assert!(
15488            checkpoint.contains(r#""componentStyles":"#)
15489                && checkpoint.contains(r#""strategy":"link""#)
15490                && checkpoint.contains(r#""static-shell":["static-shell","static-hidden"]"#),
15491            "checkpoint is missing tree-local style metadata: {checkpoint}"
15492        );
15493        for resource in ["static-shell", "static-hidden", "route-shell", "route-page"] {
15494            assert_eq!(
15495                writer
15496                    .output
15497                    .matches(&format!(r#"data-webui-resource="{resource}""#))
15498                    .count(),
15499                1,
15500                "Document delivery state must persist across streamed checkpoints"
15501            );
15502        }
15503    }
15504
15505    #[test]
15506    fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
15507        // Three boundaries: comp-a, comp-b, then comp-a reused. Each checkpoint
15508        // envelope must be locally scoped — templates/state for exactly the
15509        // components rendered since the previous checkpoint, with reused tags
15510        // carrying hydration state but never a duplicate template.
15511        let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15512        let handler = WebUIHandler::with_plugin(|| {
15513            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15514        });
15515        let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
15516        let mut writer = FlushTestWriter::default();
15517        handler
15518            .render_streaming(
15519                &protocol,
15520                &state,
15521                &RenderOptions::new("index.html", "/"),
15522                &mut writer,
15523            )
15524            .unwrap();
15525
15526        assert_eq!(
15527            writer.flushes.len(),
15528            5,
15529            "bootstrap, three boundary commits, and one terminal"
15530        );
15531        let segment = |index: usize| -> &str {
15532            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15533        };
15534        let b0 = segment(0);
15535        let b1 = segment(1);
15536        let b2 = segment(2);
15537
15538        // Boundary 0: only comp-a's template/state; no comp-b artifacts.
15539        assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
15540        assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
15541        assert!(
15542            b0.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
15543            "b0: {b0}"
15544        );
15545        assert!(b0.contains(r#""inventory":"01""#), "b0: {b0}");
15546        assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
15547        assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
15548
15549        // Boundary 1: only comp-b's template/state; no duplicate comp-a template.
15550        assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
15551        assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
15552        assert!(
15553            b1.contains(r#"[1,0,1,{"declarationId":1,"componentStyles":"#),
15554            "b1: {b1}"
15555        );
15556        assert!(b1.contains(r#""inventory":"02""#), "b1: {b1}");
15557        assert!(
15558            !b1.contains(r#""templates":{"comp-a"#),
15559            "b1 re-sent comp-a: {b1}"
15560        );
15561        assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
15562
15563        // Boundary 2: comp-a reused — state present, template absent (empty delta).
15564        assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
15565        assert!(
15566            b2.contains(r#"[2,0,2,{"declarationId":2,"componentStyles":"#),
15567            "b2: {b2}"
15568        );
15569        assert!(b2.contains(r#""inventory":"""#), "b2: {b2}");
15570        assert!(
15571            !b2.contains(r#""templates""#),
15572            "b2 re-sent a template: {b2}"
15573        );
15574
15575        // Server-only state never leaks into any envelope.
15576        assert!(!writer.output.contains("serverOnly"));
15577    }
15578
15579    #[test]
15580    fn streaming_checkpoints_emit_style_resources_and_closures_once() {
15581        let protocol = styled_disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15582        let handler = WebUIHandler::with_plugin(|| {
15583            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15584        });
15585        let mut writer = FlushTestWriter::default();
15586        handler
15587            .render_streaming(
15588                &protocol,
15589                &test_json!({ "a_count": 1, "b_count": 2 }),
15590                &RenderOptions::new("index.html", "/"),
15591                &mut writer,
15592            )
15593            .unwrap();
15594
15595        let segment = |index: usize| -> &str {
15596            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15597        };
15598        let checkpoint_styles = |checkpoint: &str| -> Value {
15599            let script = checkpoint
15600                .rfind(r#"<script type="application/json" data-webui-boundary"#)
15601                .unwrap();
15602            let payload_start = script + checkpoint[script..].find('>').unwrap() + 1;
15603            let payload_end =
15604                payload_start + checkpoint[payload_start..].find("</script>").unwrap();
15605            let record: Value =
15606                serde_json::from_str(&checkpoint[payload_start..payload_end]).unwrap();
15607            record[3]["componentStyles"].clone()
15608        };
15609
15610        let first = checkpoint_styles(segment(0));
15611        assert!(first["resources"]["comp-a"].is_object());
15612        assert!(first["resources"]["comp-b"].is_object());
15613        assert!(first["closures"]["index.html"].is_array());
15614        assert!(first["closures"]["comp-a"].is_array());
15615
15616        let second = checkpoint_styles(segment(1));
15617        assert_eq!(second["resources"], test_json!({}));
15618        assert_eq!(second["closures"], test_json!({ "comp-b": ["comp-b"] }));
15619
15620        let repeated = checkpoint_styles(segment(2));
15621        assert_eq!(repeated["resources"], test_json!({}));
15622        assert_eq!(repeated["closures"], test_json!({}));
15623        for resource in ["comp-a", "comp-b"] {
15624            assert_eq!(
15625                [&first, &second, &repeated]
15626                    .iter()
15627                    .filter(|styles| {
15628                        styles["resources"]
15629                            .as_object()
15630                            .is_some_and(|resources| resources.contains_key(resource))
15631                    })
15632                    .count(),
15633                1,
15634                "{resource} style metadata was serialized more than once"
15635            );
15636        }
15637    }
15638
15639    #[test]
15640    fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
15641        let entry = vec![
15642            WebUIFragment::raw("<html><head>"),
15643            structural_fragment("head_start"),
15644            structural_fragment("head_end"),
15645            WebUIFragment::raw("</head><body>"),
15646            structural_fragment("body_start"),
15647            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15648            WebUIFragment::raw("<comp-a"),
15649            structural_fragment("streaming_root:comp-a"),
15650            WebUIFragment::raw(">"),
15651            WebUIFragment::component("comp-a"),
15652            WebUIFragment::raw("</comp-a>"),
15653            WebUIFragment::boundary_end(0),
15654            WebUIFragment::boundary(1, "index.html", "boundary-1", None),
15655            WebUIFragment::raw("<comp-hidden"),
15656            structural_fragment("streaming_root:comp-hidden"),
15657            WebUIFragment::raw(">"),
15658            WebUIFragment::component("comp-hidden"),
15659            WebUIFragment::raw("</comp-hidden>"),
15660            WebUIFragment::boundary_end(1),
15661            structural_fragment("body_end"),
15662            WebUIFragment::raw("</body></html>"),
15663        ];
15664        let fragments = HashMap::from([
15665            (
15666                "index.html".to_string(),
15667                FragmentList {
15668                    fragments: entry,
15669                    contains_boundary: true,
15670                },
15671            ),
15672            (
15673                "comp-a".to_string(),
15674                FragmentList {
15675                    fragments: vec![WebUIFragment::if_cond(
15676                        ConditionExpr::identifier("show_hidden"),
15677                        "hidden-if",
15678                    )],
15679                    contains_boundary: false,
15680                },
15681            ),
15682            (
15683                "hidden-if".to_string(),
15684                FragmentList {
15685                    fragments: vec![WebUIFragment::component("comp-hidden")],
15686                    contains_boundary: false,
15687                },
15688            ),
15689            (
15690                "comp-hidden".to_string(),
15691                FragmentList {
15692                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15693                    contains_boundary: false,
15694                },
15695            ),
15696        ]);
15697        let mut document = WebUIProtocol::new(fragments);
15698        document.set_css_strategy(webui_protocol::CssStrategy::Module);
15699        document.initial_state_strategy = InitialStateStrategy::Components as i32;
15700        document.components.insert(
15701            "comp-a".to_string(),
15702            webui_protocol::ComponentData {
15703                template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
15704                hydration_mode: StateProjectionMode::Keys as i32,
15705                hydration_keys: vec!["show_hidden".to_string()],
15706                ..Default::default()
15707            },
15708        );
15709        document.components.insert(
15710            "comp-hidden".to_string(),
15711            webui_protocol::ComponentData {
15712                template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
15713                css: ".hidden{display:block}".to_string(),
15714                hydration_mode: StateProjectionMode::Keys as i32,
15715                hydration_keys: vec!["hidden_count".to_string()],
15716                ..Default::default()
15717            },
15718        );
15719        document.populate_style_closures(&["index.html"]);
15720        let protocol = Protocol::new(document);
15721        let handler = WebUIHandler::with_plugin(|| {
15722            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15723        });
15724        let mut writer = FlushTestWriter::default();
15725
15726        handler
15727            .render_streaming(
15728                &protocol,
15729                &test_json!({
15730                    "show_hidden": false,
15731                    "hidden_count": 7,
15732                    "unrelated": "private",
15733                }),
15734                &RenderOptions::new("index.html", "/"),
15735                &mut writer,
15736            )
15737            .unwrap();
15738
15739        let boundary = |index: usize| -> &str {
15740            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15741        };
15742        let first = boundary(0);
15743        let second = boundary(1);
15744
15745        assert!(first.contains(r#""comp-a":"#), "first: {first}");
15746        assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
15747        assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
15748        assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
15749        assert!(first.contains(r#""inventory":"01""#), "first: {first}");
15750        assert!(
15751            first.contains(r#"<script type="importmap""#),
15752            "first: {first}"
15753        );
15754        assert!(
15755            !first.contains("unrelated"),
15756            "first leaked unrelated state: {first}"
15757        );
15758
15759        assert!(
15760            !second.contains(r#""templates""#),
15761            "second re-sent metadata: {second}"
15762        );
15763        assert!(
15764            !second.contains(r#"<script type="importmap""#),
15765            "second re-sent CSS: {second}"
15766        );
15767        assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
15768        assert!(second.contains(r#""inventory":"02""#), "second: {second}");
15769        assert_eq!(
15770            writer
15771                .output
15772                .matches(r#"<script type="importmap">"#)
15773                .count(),
15774            1,
15775            "import-map output: {}",
15776            writer.output
15777        );
15778    }
15779
15780    #[test]
15781    fn streaming_flush_count_is_boundaries_plus_one() {
15782        for &count in &[1usize, 3, 10, 100] {
15783            let hosts: Vec<&str> = (0..count)
15784                .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
15785                .collect();
15786            let protocol = disjoint_streaming_protocol(&hosts);
15787            let handler = WebUIHandler::with_plugin(|| {
15788                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15789            });
15790            let mut writer = FlushTestWriter::default();
15791            handler
15792                .render_streaming(
15793                    &protocol,
15794                    &test_json!({ "a_count": 1, "b_count": 2 }),
15795                    &RenderOptions::new("index.html", "/"),
15796                    &mut writer,
15797                )
15798                .unwrap();
15799            assert_eq!(
15800                writer.flushes.len(),
15801                count + 2,
15802                "expected bootstrap + boundaries + terminal flushes for {count} boundaries"
15803            );
15804        }
15805    }
15806
15807    #[test]
15808    fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
15809        let mut fragments = HashMap::new();
15810        fragments.insert("comp-a".to_string(), FragmentList::default());
15811        fragments.insert("comp-b".to_string(), FragmentList::default());
15812        let mut protocol = WebUIProtocol::new(fragments);
15813        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
15814        protocol.components.insert(
15815            "comp-a".to_string(),
15816            webui_protocol::ComponentData {
15817                hydration_mode: StateProjectionMode::Keys as i32,
15818                hydration_keys: vec!["a".to_string(), "shared".to_string()],
15819                ..Default::default()
15820            },
15821        );
15822        protocol.components.insert(
15823            "comp-b".to_string(),
15824            webui_protocol::ComponentData {
15825                hydration_mode: StateProjectionMode::Keys as i32,
15826                hydration_keys: vec!["b".to_string(), "shared".to_string()],
15827                ..Default::default()
15828            },
15829        );
15830
15831        let protocol = Protocol::new(protocol);
15832        let component_index = protocol.component_index();
15833        let reachability = protocol.component_reachability();
15834        let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
15835        assert!(!collect_hydration_key_ids_into(
15836            protocol.protocol(),
15837            reachability,
15838            [component_index["comp-a"]],
15839            &mut scratch,
15840        ));
15841        let keys = scratch
15842            .iter()
15843            .filter_map(|id| reachability.hydration_key(*id))
15844            .collect::<Vec<_>>();
15845        assert_eq!(keys, ["a", "shared"]);
15846        let pointer = scratch.as_ptr();
15847        let capacity = scratch.capacity();
15848
15849        assert!(!collect_hydration_key_ids_into(
15850            protocol.protocol(),
15851            reachability,
15852            [component_index["comp-b"]],
15853            &mut scratch,
15854        ));
15855        let keys = scratch
15856            .iter()
15857            .filter_map(|id| reachability.hydration_key(*id))
15858            .collect::<Vec<_>>();
15859        assert_eq!(keys, ["b", "shared"]);
15860        assert_eq!(scratch.as_ptr(), pointer);
15861        assert_eq!(scratch.capacity(), capacity);
15862    }
15863}